From ea49c1f283179a6e5dac32c59ba1a7dae3c6bf0e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 14:39:49 +0200 Subject: [PATCH 001/194] feat(agent): add bounded invocation executor channel foundation --- src/agent/executor/channel.test.ts | 359 ++++++++++++++ src/agent/executor/channel.ts | 735 ++++++++++++++++++++++++++++ src/agent/executor/protocol.test.ts | 700 ++++++++++++++++++++++++++ src/agent/executor/protocol.ts | 145 ++++++ 4 files changed, 1939 insertions(+) create mode 100644 src/agent/executor/channel.test.ts create mode 100644 src/agent/executor/channel.ts create mode 100644 src/agent/executor/protocol.test.ts create mode 100644 src/agent/executor/protocol.ts diff --git a/src/agent/executor/channel.test.ts b/src/agent/executor/channel.test.ts new file mode 100644 index 0000000000..04c1ebb59c --- /dev/null +++ b/src/agent/executor/channel.test.ts @@ -0,0 +1,359 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createExecutorChannel, type ExecutorOperation } from "./channel.ts"; +import { EXECUTOR_STREAM_WINDOW, type ExecutorBinding } from "./protocol.ts"; + +const binding: ExecutorBinding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", +}; + +function pair( + operations: ReadonlyMap = new Map(), + maxConcurrentCalls = 32, + callerOperations: ReadonlyMap = new Map(), +) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + maxConcurrentCalls, + operations: callerOperations, + }); + const receiver = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + maxConcurrentCalls, + }); + return { caller, receiver }; +} + +async function tick(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("executor channel", () => { + it("supports independently registered operations in both directions", async () => { + const { caller, receiver } = pair( + new Map([ + ["relay", { mode: "unary", handle: (input) => receiver.request("double", input) }], + ]), + 2, + new Map([ + ["double", { + mode: "unary", + handle: (input) => { + if (typeof input !== "number") throw new Error("Expected numeric input"); + return input * 2; + }, + }], + ]), + ); + try { + assertEquals(await caller.request("relay", 3), 6); + assertEquals(await receiver.request("double", 4), 8); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("correlates concurrent calls and binds handler context to one invocation", async () => { + const { caller, receiver } = pair( + new Map([ + ["echo", { + mode: "unary", + handle: (value, context) => { + assertEquals(context.binding, binding); + assert(context.deadline > Date.now()); + assertEquals(context.signal.aborted, false); + return value; + }, + }], + ]), + ); + try { + await Promise.all([caller.ready, receiver.ready]); + assertEquals( + await Promise.all([ + caller.request("echo", { item: 1 }), + caller.request("echo", ["two", null]), + ]), + [{ item: 1 }, ["two", null]], + ); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("registers operations explicitly and keeps handler failures out of replies", async () => { + const { caller, receiver } = pair( + new Map([ + ["fail", { + mode: "unary", + handle: () => { + throw new Error("private synthetic detail"); + }, + }], + ]), + ); + try { + await assertRejects(() => caller.request("missing", null), Error, "operation-not-found"); + await assertRejects(() => caller.request("fail", null), Error, "operation-failed"); + assertEquals(caller.signal.aborted, false); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("stops producing at the credit window and returns credit only on consumption", async () => { + let produced = 0; + const { caller, receiver } = pair( + new Map([ + ["items", { + mode: "stream", + handle: async function* () { + for (let index = 0; index < 20; index++) { + produced++; + yield index; + } + }, + }], + ]), + ); + try { + const stream = caller.stream("items", null); + await tick(); + assertEquals(produced, EXECUTOR_STREAM_WINDOW); + assertEquals(await stream.next(), { done: false, value: 0 }); + await tick(); + assertEquals(produced, EXECUTOR_STREAM_WINDOW + 1); + const values = [0]; + for await (const value of stream) values.push(value as number); + assertEquals(values, Array.from({ length: 20 }, (_, index) => index)); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("counts completed but unread streams against admission limits", async () => { + const { caller, receiver } = pair( + new Map([ + ["one", { + mode: "stream", + handle: async function* () { + yield 1; + }, + }], + ]), + 1, + ); + try { + const stream = caller.stream("one", null); + await tick(); + await assertRejects(() => caller.request("missing", null), Error, "concurrent"); + assertEquals(await stream.next(), { done: false, value: 1 }); + assertEquals(await stream.next(), { done: true, value: undefined }); + await assertRejects(() => caller.request("missing", null), Error, "operation-not-found"); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("propagates per-call abort while retaining the channel", async () => { + const observed = Promise.withResolvers(); + const started = Promise.withResolvers(); + const { caller, receiver } = pair( + new Map([ + ["wait", { + mode: "unary", + handle: async (_, { signal }) => { + started.resolve(); + await new Promise((resolve) => { + signal.addEventListener("abort", () => { + observed.resolve(); + resolve(); + }, { once: true }); + }); + return null; + }, + }], + ]), + ); + try { + const controller = new AbortController(); + const result = caller.request("wait", null, { signal: controller.signal }); + const rejected = assertRejects(() => result, Error, "cancelled"); + await started.promise; + controller.abort(); + await Promise.all([rejected, observed.promise]); + await tick(); + assertEquals(caller.signal.aborted, false); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("aborts handlers on deadline and channel disconnect", async () => { + for (const disconnect of [false, true]) { + const started = Promise.withResolvers(); + const { caller, receiver } = pair( + new Map([ + ["wait", { + mode: "unary", + handle: async (_, { signal }) => { + started.resolve(signal); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return null; + }, + }], + ]), + ); + try { + const result = caller.request("wait", null, { timeoutMs: disconnect ? 1_000 : 30 }); + const rejected = assertRejects(() => result); + const signal = await started.promise; + if (disconnect) receiver.close(); + await rejected; + await tick(); + assertEquals(signal.aborted, true); + if (disconnect) await assertRejects(() => caller.request("wait", null), Error, "closed"); + } finally { + caller.close(); + await receiver.closed; + } + } + }); + + it("cancels a stream when the consumer exits", async () => { + const aborted = Promise.withResolvers(); + const { caller, receiver } = pair( + new Map([ + ["items", { + mode: "stream", + handle: async function* (_, { signal }) { + signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + while (!signal.aborted) yield 1; + }, + }], + ]), + ); + try { + for await (const _ of caller.stream("items", null)) break; + await aborted.promise; + await tick(); + assertEquals(caller.signal.aborted, false); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("rejects invalid handler output without exposing its serialization detail", async () => { + const { caller, receiver } = pair( + new Map([ + ["invalid", { mode: "unary", handle: () => undefined as unknown as null }], + ]), + ); + try { + await assertRejects(() => caller.request("invalid", null), Error, "operation-failed"); + assertEquals(caller.signal.aborted, false); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("retains the deadline while a completed stream remains unread", async () => { + const { caller, receiver } = pair( + new Map([ + ["one", { + mode: "stream", + handle: async function* () { + yield 1; + }, + }], + ]), + ); + try { + const stream = caller.stream("one", null, { timeoutMs: 10 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + await assertRejects(() => stream.next(), Error, "deadline"); + assertEquals(caller.signal.aborted, false); + } finally { + caller.close(); + await receiver.closed; + } + }); + + it("handles channel closure from operation completion cleanup", async () => { + const channels = pair( + new Map([ + ["finish", { + mode: "unary", + handle: (_, { signal }) => { + signal.addEventListener("abort", () => receiver.close(), { once: true }); + return null; + }, + }], + ]), + ); + const receiver = channels.receiver; + const outcome = channels.caller.request("finish", null).then( + () => "resolved", + () => "rejected", + ); + await receiver.closed; + assertEquals(await outcome, "rejected"); + channels.caller.close(); + }); + + it("waits for cooperative cleanup acknowledgement before reusing a call slot", async () => { + const cleanup = Promise.withResolvers(); + const cleaning = Promise.withResolvers(); + const { caller, receiver } = pair( + new Map([ + ["items", { + mode: "stream", + handle: async function* () { + try { + while (true) yield 1; + } finally { + cleaning.resolve(); + await cleanup.promise; + } + }, + }], + ["echo", { mode: "unary", handle: (value) => value }], + ]), + 1, + ); + try { + const stream = caller.stream("items", null); + await stream.next(); + const returning = stream.return!(); + await cleaning.promise; + await assertRejects(() => caller.request("echo", null), Error, "concurrent"); + assertEquals(caller.signal.aborted, false); + assertEquals(receiver.signal.aborted, false); + cleanup.resolve(); + await returning; + assertEquals(await caller.request("echo", 3), 3); + } finally { + cleanup.resolve(); + caller.close(); + await receiver.closed; + } + }); +}); diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts new file mode 100644 index 0000000000..ab745d4bc3 --- /dev/null +++ b/src/agent/executor/channel.ts @@ -0,0 +1,735 @@ +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import { + encodeExecutorFrame, + EXECUTOR_MAX_CONCURRENT_CALLS, + EXECUTOR_MAX_FRAME_BYTES, + EXECUTOR_MAX_RETAINED_BYTES, + EXECUTOR_MAX_TIMEOUT_MS, + EXECUTOR_PROTOCOL_VERSION, + EXECUTOR_STREAM_WINDOW, + type ExecutorBinding, + type ExecutorFrame, + type ExecutorMessage, + ExecutorProtocolError, + getExecutorBindingSchema, + readExecutorFrames, +} from "./protocol.ts"; + +/** A connection authenticated by its owner before channel construction. No reconnect or replay. */ +export interface ExecutorByteTransport { + readable: ReadableStream; + writable: WritableStream; +} + +/** Operation handlers validate their own payload schema before performing any privileged action. */ +export interface ExecutorOperationContext { + readonly binding: Readonly; + readonly signal: AbortSignal; + readonly deadline: number; +} + +/** Explicit local allowlist. Handler errors are replaced with a fixed wire error. */ +export type ExecutorOperation = + | { + mode: "unary"; + handle: (input: JsonValue, context: ExecutorOperationContext) => JsonValue | Promise; + } + | { + mode: "stream"; + handle: (input: JsonValue, context: ExecutorOperationContext) => AsyncIterable; + }; + +export interface ExecutorCallOptions { + signal?: AbortSignal; + /** Covers readiness, execution, and consumption. Defaults to the channel timeout. */ + timeoutMs?: number; +} + +export interface ExecutorChannelOptions { + binding: ExecutorBinding; + transport: ExecutorByteTransport; + operations?: ReadonlyMap; + /** Per direction, including completed streams with unread data. Maximum 32. */ + maxConcurrentCalls?: number; + /** Maximum lifetime also enforced on incoming calls. Default 30 seconds, maximum 24 hours. */ + defaultTimeoutMs?: number; + /** Default five seconds, maximum one minute. */ + handshakeTimeoutMs?: number; + /** Close if cancellation is unacknowledged or a handler does not settle. Default five seconds. */ + cancellationTimeoutMs?: number; + /** Aggregate JSON payload bytes retained by requests and unread results. Default and maximum 8 MiB. */ + maxRetainedPayloadBytes?: number; +} + +export interface ExecutorChannel { + readonly ready: Promise; + /** Resolves on closure; the fixed diagnostic never includes transport or handler error text. */ + readonly closed: Promise; + readonly signal: AbortSignal; + request(operation: string, input: JsonValue, options?: ExecutorCallOptions): Promise; + /** Single consumer. Concurrent next() calls reject without adding a waiter. */ + stream( + operation: string, + input: JsonValue, + options?: ExecutorCallOptions, + ): AsyncIterableIterator; + close(): void; +} + +type EndError = Extract["error"]; +type Deferred = ReturnType>; + +interface OutgoingCall { + id: number; + deadline: number; + pendingRequest?: Extract; + inputBytes: number; + mode: "unary" | "stream"; + queue: { value: JsonValue; bytes: number }[]; + completion: Deferred; + releaseAck?: Deferred; + received: number; + consumed: number; + unaryBytes: number; + ended: boolean; + released: boolean; + release?: Promise; + cancelled: boolean; + reading: boolean; + error?: Error; + wake?: () => void; + timer?: ReturnType; + cancellationTimer?: ReturnType; + removeAbortListener?: () => void; +} + +interface IncomingCall { + id: number; + deadline: number; + payloadBytes: number; + controller: AbortController; + mode: "unary" | "stream"; + sent: number; + consumed: number; + ended: boolean; + released: boolean; + cancelled: boolean; + settled: boolean; + wake?: () => void; + timer?: ReturnType; + cancellationTimer?: ReturnType; +} + +function positiveBound(value: number, maximum: number): number { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw new TypeError("Executor channel option exceeds its positive integer limit"); + } + return value; +} + +/** + * Create an internal invocation channel. This module makes no authentication, + * network, credential, or project-loading decisions. Register SchemaValidator + * before construction. Transport closure revokes this channel permanently. + */ +export function createExecutorChannel(options: ExecutorChannelOptions): ExecutorChannel { + return new Channel(options); +} + +class Channel implements ExecutorChannel { + readonly #binding: Readonly; + readonly #reader: ReadableStreamDefaultReader; + readonly #writer: WritableStreamDefaultWriter; + readonly #operations: ReadonlyMap; + readonly #maxCalls: number; + readonly #timeout: number; + readonly #cancellationTimeout: number; + readonly #maxRetainedBytes: number; + #retainedBytes = 0; + readonly #controller = new AbortController(); + readonly #ready = Promise.withResolvers(); + readonly #closed = Promise.withResolvers(); + readonly #outgoing = new Set(); + readonly #outgoingById = new Map(); + readonly #incoming = new Map(); + readonly #writes: { bytes: Uint8Array; done: Deferred }[] = []; + #queuedBytes = 0; + #writing = false; + #sendSequence = 0; + #receiveSequence = 0; + #nextId = 0; + #lastReceivedId = 0; + #receivedHello = false; + #error?: Error; + #handshakeTimer: ReturnType; + + constructor(options: ExecutorChannelOptions) { + this.#binding = Object.freeze(getExecutorBindingSchema().parse(options.binding)); + this.#maxCalls = positiveBound(options.maxConcurrentCalls ?? 32, EXECUTOR_MAX_CONCURRENT_CALLS); + this.#timeout = positiveBound(options.defaultTimeoutMs ?? 30_000, EXECUTOR_MAX_TIMEOUT_MS); + this.#cancellationTimeout = positiveBound(options.cancellationTimeoutMs ?? 5_000, 60_000); + this.#maxRetainedBytes = positiveBound( + options.maxRetainedPayloadBytes ?? EXECUTOR_MAX_RETAINED_BYTES, + EXECUTOR_MAX_RETAINED_BYTES, + ); + const handshakeTimeout = positiveBound(options.handshakeTimeoutMs ?? 5_000, 60_000); + this.#operations = new Map(options.operations); + this.#reader = options.transport.readable.getReader(); + this.#writer = options.transport.writable.getWriter(); + this.#handshakeTimer = setTimeout( + () => this.#fail("Executor handshake deadline exceeded"), + handshakeTimeout, + ); + // Readiness remains rejectable for callers that await it, even when nobody observes it yet. + void this.ready.catch(() => {}); + this.#control({ type: "hello" }); + void this.#receive(); + } + + get ready(): Promise { + return this.#ready.promise; + } + get closed(): Promise { + return this.#closed.promise; + } + get signal(): AbortSignal { + return this.#controller.signal; + } + + close(): void { + this.#fail("Executor channel closed"); + } + + async request( + operation: string, + input: JsonValue, + options: ExecutorCallOptions = {}, + ): Promise { + const call = this.#start(operation, "unary", input, options); + try { + const first = await this.#next(call); + if (first.done) throw new Error("Executor unary result is missing"); + await this.#next(call); + return first.value; + } finally { + try { + await call.completion.promise; + } finally { + this.#retainedBytes -= call.unaryBytes; + call.unaryBytes = 0; + } + } + } + + stream( + operation: string, + input: JsonValue, + options: ExecutorCallOptions = {}, + ): AsyncIterableIterator { + const call = this.#start(operation, "stream", input, options); + return { + next: () => this.#next(call), + return: async () => { + this.#cancelOutgoing(call, "cancelled"); + await call.completion.promise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + } + + #start( + operation: string, + mode: "unary" | "stream", + input: JsonValue, + options: ExecutorCallOptions, + ): OutgoingCall { + if (this.#error) throw new Error("Executor channel closed"); + if (options.signal?.aborted) throw new Error("Executor call cancelled"); + if (this.#outgoing.size >= this.#maxCalls) { + throw new Error("Executor concurrent call limit exceeded"); + } + const timeoutMs = positiveBound(options.timeoutMs ?? this.#timeout, this.#timeout); + const snapshot = snapshotBoundedJsonValue(input); + if (!snapshot.success) throw new TypeError("Executor call requires bounded JSON data"); + const message: Extract = { + type: "request", + id: 1, + operation, + mode, + timeoutMs, + value: snapshot.value, + }; + // Reject invalid local input before reserving a call or emitting any bytes. + encodeExecutorFrame({ + version: EXECUTOR_PROTOCOL_VERSION, + binding: this.#binding, + sequence: 0, + message, + }); + const call: OutgoingCall = { + id: 0, + deadline: Date.now() + timeoutMs, + pendingRequest: message, + inputBytes: this.#retainPayload(snapshot.value), + mode, + queue: [], + completion: Promise.withResolvers(), + received: 0, + consumed: 0, + unaryBytes: 0, + ended: false, + released: false, + cancelled: false, + reading: false, + }; + void call.completion.promise.catch(() => {}); + this.#outgoing.add(call); + call.timer = setTimeout(() => this.#cancelOutgoing(call, "deadline"), timeoutMs); + if (options.signal) { + const signal = options.signal; + const abort = () => this.#cancelOutgoing(call, "cancelled"); + signal.addEventListener("abort", abort, { once: true }); + call.removeAbortListener = () => signal.removeEventListener("abort", abort); + if (signal.aborted) abort(); + } + if (this.#receivedHello) void this.#beginOutgoing(call); + return call; + } + + async #beginOutgoing(call: OutgoingCall): Promise { + const message = call.pendingRequest; + if (!message || call.released || call.cancelled || this.#error) return; + const inputBytes = call.inputBytes; + call.pendingRequest = undefined; + call.inputBytes = 0; + try { + const remaining = call.deadline - Date.now(); + if (remaining <= 0) { + this.#cancelOutgoing(call, "deadline"); + return; + } + call.id = ++this.#nextId; + this.#outgoingById.set(call.id, call); + await this.#send({ ...message, id: call.id, timeoutMs: remaining }); + } catch { + if (!this.#error) this.#fail("Executor channel write failed"); + } finally { + this.#retainedBytes -= inputBytes; + } + } + + async #next(call: OutgoingCall): Promise> { + if (call.reading) throw new Error("Executor stream permits one pending consumer read"); + call.reading = true; + try { + while (!call.error && !call.queue.length && !call.ended) { + await new Promise((resolve) => { + call.wake = resolve; + }); + } + if (call.error) throw call.error; + if (!call.queue.length) { + await this.#releaseOutgoing(call); + return { done: true, value: undefined }; + } + const { value, bytes } = call.queue.shift()!; + if (call.mode === "unary") call.unaryBytes = bytes; + try { + call.consumed++; + if (call.mode === "stream") { + await this.#send({ type: "credit", id: call.id, consumed: call.consumed }); + } + if (call.error) throw call.error; + if (call.ended && !call.queue.length) await this.#releaseOutgoing(call); + return { done: false, value }; + } finally { + if (call.mode === "stream") this.#retainedBytes -= bytes; + } + } finally { + call.reading = false; + call.wake = undefined; + } + } + + #cancelOutgoing(call: OutgoingCall, reason: "cancelled" | "deadline"): void { + if (call.released || call.cancelled) return; + call.cancelled = true; + call.error = new Error(`Executor call ${reason}`); + this.#clearResults(call); + call.wake?.(); + this.#clearCallTimers(call); + if (!call.id || call.ended) { + void this.#releaseOutgoing(call).catch(() => {}); + } else { + this.#control({ type: "cancel", id: call.id }); + call.cancellationTimer = setTimeout( + () => this.#fail("Executor cancellation deadline exceeded"), + this.#cancellationTimeout, + ); + } + } + + #releaseOutgoing(call: OutgoingCall): Promise { + if (call.release) return call.release; + call.released = true; + this.#clearCallTimers(call); + this.#clearPendingInput(call); + call.release = (async () => { + try { + // Keep the admission slot and a deadline until release is written. + if (call.id && !this.#error) { + call.releaseAck = Promise.withResolvers(); + const acknowledgement = call.releaseAck; + void acknowledgement.promise.catch(() => {}); + call.cancellationTimer = setTimeout( + () => this.#fail("Executor release write deadline exceeded"), + this.#cancellationTimeout, + ); + await this.#send({ type: "release", id: call.id }); + await acknowledgement.promise; + } + call.completion.resolve(); + } catch (error) { + call.completion.reject(error); + throw error; + } finally { + this.#clearCallTimers(call); + this.#outgoing.delete(call); + this.#outgoingById.delete(call.id); + } + })(); + return call.release; + } + + async #receive(): Promise { + try { + for await (const frame of readExecutorFrames(this.#reader)) { + if (this.#error) return; + this.#accept(frame); + } + this.#fail("Executor channel disconnected"); + } catch (error) { + // Protocol diagnostics are fixed; transport exceptions may contain private details. + this.#fail( + error instanceof ExecutorProtocolError ? error.message : "Executor channel read failed", + ); + } finally { + this.#reader.releaseLock(); + } + } + + #accept(frame: ExecutorFrame): void { + if ( + frame.binding.allocationId !== this.#binding.allocationId || + frame.binding.generation !== this.#binding.generation || + frame.binding.invocationId !== this.#binding.invocationId + ) { + throw new ExecutorProtocolError("Executor channel identity mismatch"); + } + if (frame.sequence !== this.#receiveSequence++) { + throw new ExecutorProtocolError("Executor frame sequence violation"); + } + const message = frame.message; + if (!this.#receivedHello) { + if (message.type !== "hello") { + throw new ExecutorProtocolError("Executor hello frame required"); + } + this.#receivedHello = true; + clearTimeout(this.#handshakeTimer); + this.#ready.resolve(); + for (const call of this.#outgoing) void this.#beginOutgoing(call); + return; + } + if (message.type === "hello") throw new ExecutorProtocolError("Executor duplicate hello frame"); + if (message.type === "request") { + this.#acceptRequest(message); + return; + } + if (message.type === "released") { + const call = this.#outgoingById.get(message.id); + if (!call?.releaseAck || !call.ended) { + throw new ExecutorProtocolError("Executor unknown release acknowledgement"); + } + call.releaseAck.resolve(); + call.releaseAck = undefined; + return; + } + if (message.type === "data" || message.type === "end") { + const call = this.#outgoingById.get(message.id); + if (!call || call.ended || call.released) { + throw new ExecutorProtocolError("Executor unknown or completed response"); + } + if (message.type === "data") { + if ( + message.index !== call.received || + call.received - call.consumed >= (call.mode === "stream" ? EXECUTOR_STREAM_WINDOW : 1) || + (call.mode === "unary" && call.received !== 0) + ) throw new ExecutorProtocolError("Executor result sequence or credit violation"); + call.received++; + if (!call.cancelled) { + const bytes = this.#retainPayload(message.value); + call.queue.push({ value: message.value, bytes }); + } + } else { + call.ended = true; + if (!message.error && !call.cancelled && call.mode === "unary" && call.received !== 1) { + throw new ExecutorProtocolError("Executor unary result is missing"); + } + if (message.error) { + call.error ??= new Error(`Executor call ${message.error}`); + this.#clearResults(call); + } + if (!call.queue.length) void this.#releaseOutgoing(call).catch(() => {}); + } + call.wake?.(); + return; + } + const call = this.#incoming.get(message.id); + if (!call || call.released) throw new ExecutorProtocolError("Executor unknown call control"); + if (message.type === "credit") { + if ( + call.mode !== "stream" || message.consumed !== call.consumed + 1 || + message.consumed > call.sent + ) { + throw new ExecutorProtocolError("Executor consumption credit violation"); + } + call.consumed = message.consumed; + call.wake?.(); + } else if (message.type === "cancel") { + if (call.cancelled) throw new ExecutorProtocolError("Executor duplicate cancellation"); + call.cancelled = true; + this.#abortIncoming(call, "cancelled"); + } else { + if (!call.ended) throw new ExecutorProtocolError("Executor release before completion"); + call.released = true; + this.#removeIncoming(call); + } + } + + #acceptRequest(message: Extract): void { + if (message.id <= this.#lastReceivedId) { + throw new ExecutorProtocolError("Executor request sequence violation"); + } + if (this.#incoming.size >= this.#maxCalls) { + throw new ExecutorProtocolError("Executor concurrent request limit exceeded"); + } + this.#lastReceivedId = message.id; + const timeout = Math.min(message.timeoutMs, this.#timeout); + const call: IncomingCall = { + id: message.id, + deadline: Date.now() + timeout, + payloadBytes: this.#retainPayload(message.value), + mode: message.mode, + controller: new AbortController(), + sent: 0, + consumed: 0, + ended: false, + released: false, + cancelled: false, + settled: false, + }; + this.#incoming.set(call.id, call); + call.timer = setTimeout(() => this.#abortIncoming(call, "deadline"), timeout); + void this.#run(call, message, call.deadline); + } + + async #run( + call: IncomingCall, + message: Extract, + deadline: number, + ): Promise { + let iterator: AsyncIterator | undefined; + try { + const operation = this.#operations.get(message.operation); + if (!operation) { + await this.#end(call, "operation-not-found"); + return; + } + if (operation.mode !== message.mode) { + await this.#end(call, "mode-mismatch"); + return; + } + const context = { binding: this.#binding, signal: call.controller.signal, deadline }; + if (operation.mode === "unary") { + const value = await operation.handle(message.value, context); + if (call.ended || this.#error) return; + await this.#send({ type: "data", id: call.id, index: call.sent++, value }); + } else { + iterator = operation.handle(message.value, context)[Symbol.asyncIterator](); + while (!call.ended && !this.#error) { + while ( + call.sent - call.consumed >= EXECUTOR_STREAM_WINDOW && !call.ended && !this.#error + ) { + await new Promise((resolve) => { + call.wake = resolve; + }); + } + if (call.ended || this.#error) break; + const next = await iterator.next(); + if (call.ended || this.#error || next.done) break; + await this.#send({ type: "data", id: call.id, index: call.sent++, value: next.value }); + } + } + await this.#end(call); + } catch { + await this.#end(call, "operation-failed"); + } finally { + try { + if (iterator?.return) await iterator.return(); + } catch { /* Handler cleanup cannot expose its error. */ } + call.settled = true; + this.#retainedBytes -= call.payloadBytes; + clearTimeout(call.cancellationTimer); + this.#removeIncoming(call); + } + } + + #abortIncoming(call: IncomingCall, reason: "cancelled" | "deadline"): void { + if (call.ended) return; + call.controller.abort(new Error(`Executor call ${reason}`)); + call.wake?.(); + void this.#end(call, reason); + } + + async #end(call: IncomingCall, error?: EndError): Promise { + if (call.ended || this.#error) return; + call.ended = true; + clearTimeout(call.timer); + call.controller.abort(new Error("Executor call completed")); + if (this.#error) return; + call.timer = setTimeout( + () => this.#fail("Executor completion release deadline exceeded"), + Math.max(0, call.deadline - Date.now()) + this.#cancellationTimeout, + ); + if (!call.settled) { + call.cancellationTimer = setTimeout( + () => this.#fail("Executor handler cancellation deadline exceeded"), + this.#cancellationTimeout, + ); + } + const message: ExecutorMessage = { type: "end", id: call.id, ...(error ? { error } : {}) }; + try { + await this.#send(message); + } catch { + this.#fail("Executor channel write failed"); + } + } + + #removeIncoming(call: IncomingCall): void { + if (call.released && call.settled) { + clearTimeout(call.timer); + clearTimeout(call.cancellationTimer); + this.#incoming.delete(call.id); + if (!this.#error) this.#control({ type: "released", id: call.id }); + } + } + + #control(message: ExecutorMessage): void { + void this.#send(message).catch(() => this.#fail("Executor channel write failed")); + } + + #send(message: ExecutorMessage): Promise { + if (this.#error) return Promise.reject(this.#error); + const bytes = encodeExecutorFrame({ + version: EXECUTOR_PROTOCOL_VERSION, + binding: this.#binding, + sequence: this.#sendSequence, + message, + }); + // Bound both queued bytes and control-frame bookkeeping, including the active write. + if ( + this.#writes.length >= this.#maxCalls * 4 + EXECUTOR_STREAM_WINDOW || + this.#queuedBytes + bytes.byteLength > EXECUTOR_STREAM_WINDOW * EXECUTOR_MAX_FRAME_BYTES + ) { + this.#fail("Executor write queue limit exceeded"); + return Promise.reject(this.#error); + } + this.#sendSequence++; + const done = Promise.withResolvers(); + this.#writes.push({ bytes, done }); + this.#queuedBytes += bytes.byteLength; + if (!this.#writing) void this.#flush(); + return done.promise; + } + + async #flush(): Promise { + this.#writing = true; + try { + while (this.#writes.length && !this.#error) { + const entry = this.#writes[0]!; + await this.#writer.write(entry.bytes); + if (this.#error) return; + this.#writes.shift(); + this.#queuedBytes -= entry.bytes.byteLength; + entry.done.resolve(); + } + } catch { + this.#fail("Executor channel write failed"); + } finally { + this.#writing = false; + } + } + + #clearCallTimers(call: OutgoingCall): void { + clearTimeout(call.timer); + clearTimeout(call.cancellationTimer); + call.removeAbortListener?.(); + } + + #retainPayload(value: JsonValue): number { + const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength; + if (this.#retainedBytes + bytes > this.#maxRetainedBytes) { + this.#fail("Executor retained payload budget exceeded"); + throw new ExecutorProtocolError("Executor retained payload budget exceeded"); + } + this.#retainedBytes += bytes; + return bytes; + } + + #clearResults(call: OutgoingCall): void { + for (const result of call.queue) this.#retainedBytes -= result.bytes; + call.queue.length = 0; + } + + #clearPendingInput(call: OutgoingCall): void { + this.#retainedBytes -= call.inputBytes; + call.inputBytes = 0; + call.pendingRequest = undefined; + } + + #fail(message: string): void { + if (this.#error) return; + const error = this.#error = new Error(message); + clearTimeout(this.#handshakeTimer); + this.#controller.abort(error); + this.#ready.reject(error); + for (const call of this.#outgoing) { + this.#clearCallTimers(call); + this.#clearPendingInput(call); + call.error = error; + this.#clearResults(call); + call.releaseAck?.reject(error); + call.completion.reject(error); + call.wake?.(); + } + for (const call of this.#incoming.values()) { + clearTimeout(call.timer); + clearTimeout(call.cancellationTimer); + call.controller.abort(error); + call.wake?.(); + } + this.#outgoing.clear(); + this.#outgoingById.clear(); + this.#incoming.clear(); + for (const entry of this.#writes) entry.done.reject(error); + this.#writes.length = 0; + this.#queuedBytes = 0; + void this.#reader.cancel(error).catch(() => {}); + void this.#writer.abort(error).catch(() => {}).finally(() => this.#writer.releaseLock()); + this.#closed.resolve(error); + } +} diff --git a/src/agent/executor/protocol.test.ts b/src/agent/executor/protocol.test.ts new file mode 100644 index 0000000000..bab954185f --- /dev/null +++ b/src/agent/executor/protocol.test.ts @@ -0,0 +1,700 @@ +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 { createExecutorChannel, type ExecutorOperation } from "./channel.ts"; +import { + encodeExecutorFrame, + EXECUTOR_MAX_FRAME_BYTES, + type ExecutorFrame, + type ExecutorMessage, + readExecutorFrames, +} from "./protocol.ts"; + +const binding = { allocationId: "allocation-test", generation: 1, invocationId: "invocation-test" }; + +function rawFrame(value: unknown): Uint8Array { + const data = new TextEncoder().encode(JSON.stringify(value)); + const bytes = new Uint8Array(data.byteLength + 4); + new DataView(bytes.buffer).setUint32(0, data.byteLength); + bytes.set(data, 4); + return bytes; +} + +function envelope(message: ExecutorMessage, sequence = 0): ExecutorFrame { + return { version: 1, binding, sequence, message }; +} + +function endpoint( + operations?: ReadonlyMap, + cancellationTimeoutMs = 100, + maxRetainedPayloadBytes?: number, +) { + let input!: ReadableStreamDefaultController; + const written: ExecutorFrame[] = []; + const channel = createExecutorChannel({ + binding, + operations, + cancellationTimeoutMs, + maxRetainedPayloadBytes, + transport: { + readable: new ReadableStream({ + start(controller) { + input = controller; + }, + }), + writable: new WritableStream({ + write(bytes) { + const frame = JSON.parse(new TextDecoder().decode(bytes.subarray(4))) as ExecutorFrame; + written.push(frame); + if (frame.message.type === "release") { + const id = frame.message.id; + queueMicrotask(() => { + if (!channel.signal.aborted) send({ type: "released", id }); + }); + } + }, + }), + }, + }); + let sequence = 0; + const send = (message: ExecutorMessage) => input.enqueue(rawFrame(envelope(message, sequence++))); + return { channel, input, written, send }; +} + +async function tick(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("executor byte protocol", () => { + it("decodes fragmented prefixes, split UTF-8 and coalesced frames", async () => { + const frames = [ + envelope({ type: "hello" }), + envelope({ type: "data", id: 1, index: 0, value: "héllo" }, 1), + ]; + const joined = new Uint8Array( + frames.reduce((length, frame) => length + rawFrame(frame).length, 0), + ); + let offset = 0; + for (const frame of frames) { + const bytes = rawFrame(frame); + joined.set(bytes, offset); + offset += bytes.length; + } + for (const split of [1, 2, 3, 4, 31, joined.length]) { + const input = new ReadableStream({ + start(controller) { + for (let index = 0; index < joined.length; index += split) { + controller.enqueue(joined.slice(index, index + split)); + } + controller.close(); + }, + }); + const result = []; + for await (const frame of readExecutorFrames(input.getReader())) result.push(frame); + assertEquals(result, frames); + } + }); + + it("rejects oversized frames, deep JSON, and values JSON would coerce", () => { + let deep: unknown = null; + for (let index = 0; index < 130; index++) deep = [deep]; + const cycle: unknown[] = []; + cycle.push(cycle); + const inherited = Object.create({ property: "inherited" }); + inherited.value = "synthetic"; + for ( + const value of [ + undefined, + NaN, + Infinity, + new Date(0), + 1n, + () => 1, + { toJSON: () => "synthetic" }, + inherited, + cycle, + deep, + ] + ) { + assertThrows( + () => + encodeExecutorFrame( + envelope({ type: "data", id: 1, index: 0, value } as ExecutorMessage), + ), + TypeError, + ); + } + assertThrows( + () => + encodeExecutorFrame( + envelope({ type: "data", id: 1, index: 0, value: "x".repeat(EXECUTOR_MAX_FRAME_BYTES) }), + ), + TypeError, + "byte limit", + ); + }); + + const badHello: [string, unknown, string][] = [ + ["unsupported version", { ...envelope({ type: "hello" }), version: 2 }, "Unsupported"], + ["allocation", { + ...envelope({ type: "hello" }), + binding: { ...binding, allocationId: "other-allocation" }, + }, "identity"], + [ + "generation", + { ...envelope({ type: "hello" }), binding: { ...binding, generation: 2 } }, + "identity", + ], + ["invocation", { + ...envelope({ type: "hello" }), + binding: { ...binding, invocationId: "other-invocation" }, + }, "identity"], + ["missing identity", { version: 1, sequence: 0, message: { type: "hello" } }, "schema"], + ["unknown fields", { ...envelope({ type: "hello" }), unexpected: true }, "schema"], + ["negative sequence", { ...envelope({ type: "hello" }), sequence: -1 }, "schema"], + ["first sequence", envelope({ type: "hello" }, 1), "sequence"], + ["missing hello", envelope({ type: "cancel", id: 1 }), "hello"], + ]; + for (const [label, frame, diagnostic] of badHello) { + it(`closes before readiness for invalid ${label}`, async () => { + const { channel, input } = endpoint(); + input.enqueue(rawFrame(frame)); + await assertRejects(() => channel.ready, Error, diagnostic); + assertEquals(channel.signal.aborted, true); + await channel.closed; + }); + } + + for ( + const kind of [ + "zero-length", + "oversized-prefix", + "oversized-chunk", + "invalid-json", + "invalid-utf8", + "truncated", + ] as const + ) { + it(`closes on ${kind} byte framing`, async () => { + const { channel, input } = endpoint(); + let bytes = new Uint8Array(4); + if (kind === "oversized-prefix") { + new DataView(bytes.buffer).setUint32(0, EXECUTOR_MAX_FRAME_BYTES); + } + if (kind === "oversized-chunk") bytes = new Uint8Array(EXECUTOR_MAX_FRAME_BYTES + 1); + if (kind === "invalid-json") bytes = new Uint8Array([0, 0, 0, 1, 123]); + if (kind === "invalid-utf8") bytes = new Uint8Array([0, 0, 0, 1, 255]); + if (kind === "truncated") bytes = new Uint8Array([0, 0, 0, 5, 123]); + input.enqueue(bytes); + if (kind === "truncated") input.close(); + await channel.closed; + assertEquals(channel.signal.aborted, true); + }); + } + + for (const type of ["cancel", "release", "released", "credit", "data", "end"] as const) { + it(`closes on unknown ${type} correlation`, async () => { + const { channel, send } = endpoint(); + send({ type: "hello" }); + await channel.ready; + send( + type === "credit" + ? { type, id: 1, consumed: 1 } + : type === "data" + ? { type, id: 1, index: 0, value: null } + : { type, id: 1 }, + ); + const error = await channel.closed; + assert(error.message.includes("unknown")); + }); + } + + it("rejects a replayed global sequence after a valid hello", async () => { + const { channel, input, send } = endpoint(); + send({ type: "hello" }); + await channel.ready; + input.enqueue( + rawFrame( + envelope({ + type: "request", + id: 1, + operation: "missing", + mode: "unary", + timeoutMs: 100, + value: null, + }, 0), + ), + ); + assert((await channel.closed).message.includes("sequence")); + }); + + for (const failure of ["sequence", "credit", "terminal"] as const) { + it(`rejects a stream ${failure} violation`, async () => { + const { channel, send } = endpoint(); + send({ type: "hello" }); + await channel.ready; + const stream = channel.stream("items", null); + await tick(); + if (failure === "sequence") send({ type: "data", id: 1, index: 1, value: null }); + if (failure === "credit") { + for (let index = 0; index < 9; index++) send({ type: "data", id: 1, index, value: null }); + } + if (failure === "terminal") { + send({ type: "data", id: 1, index: 0, value: null }); + send({ type: "end", id: 1 }); + send({ type: "end", id: 1 }); + } + await channel.closed; + await assertRejects(() => stream.next()); + }); + } + + it("accepts bounded results already in flight after cancellation until terminal release", async () => { + const { channel, send, written } = endpoint(); + send({ type: "hello" }); + await channel.ready; + const controller = new AbortController(); + const stream = channel.stream("items", null, { signal: controller.signal }); + await tick(); + controller.abort(); + for (let index = 0; index < 8; index++) send({ type: "data", id: 1, index, value: null }); + send({ type: "end", id: 1 }); + await tick(); + await assertRejects(() => stream.next(), Error, "cancelled"); + assertEquals(channel.signal.aborted, false); + assertEquals(written.at(-1)?.message, { type: "release", id: 1 }); + channel.close(); + }); + + it("closes for unacknowledged cancellation", async () => { + const { channel, send } = endpoint(undefined, 10); + send({ type: "hello" }); + await channel.ready; + const controller = new AbortController(); + const result = channel.request("wait", null, { signal: controller.signal }); + const rejected = assertRejects(() => result); + await tick(); + controller.abort(); + await rejected; + assert((await channel.closed).message.includes("cancellation deadline")); + }); + + it("rejects pending calls and aborts handlers when any frame is malformed", async () => { + const started = Promise.withResolvers(); + const { channel, send, input } = endpoint( + new Map([ + ["wait", { + mode: "unary", + handle: async (_, { signal }) => { + started.resolve(signal); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return null; + }, + }], + ]), + ); + send({ type: "hello" }); + await channel.ready; + const result = channel.request("remote", null); + const rejected = assertRejects(() => result); + send({ + type: "request", + id: 1, + operation: "wait", + mode: "unary", + timeoutMs: 1000, + value: null, + }); + const signal = await started.promise; + input.enqueue(rawFrame({ invalid: true })); + await rejected; + assertEquals(signal.aborted, true); + await channel.closed; + }); + + it("replaces transport error details even when they resemble protocol diagnostics", async () => { + const { channel, input, send } = endpoint(); + send({ type: "hello" }); + await channel.ready; + input.error(new Error("Executor synthetic private transport detail")); + assertEquals((await channel.closed).message, "Executor channel read failed"); + }); + + it("closes when a peer never releases a completed incoming call", async () => { + const { channel, send } = endpoint( + new Map([ + ["echo", { mode: "unary", handle: (value) => value }], + ]), + 10, + ); + send({ type: "hello" }); + await channel.ready; + send({ type: "request", id: 1, operation: "echo", mode: "unary", timeoutMs: 10, value: null }); + await new Promise((resolve) => setTimeout(resolve, 25)); + try { + assertEquals(channel.signal.aborted, true); + } finally { + channel.close(); + } + }); + + for ( + const kind of [ + "duplicate-request", + "early-release", + "early-credit", + "duplicate-credit", + ] as const + ) { + it(`closes for ${kind} on an incoming call`, async () => { + const { channel, send } = endpoint( + new Map([ + ["items", { + mode: "stream", + handle: async function* (_, { signal }) { + if (kind === "early-credit") { + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + } + for (let value = 0; value < 20; value++) yield value; + }, + }], + ]), + ); + send({ type: "hello" }); + await channel.ready; + const request: ExecutorMessage = { + type: "request", + id: 1, + operation: "items", + mode: "stream", + timeoutMs: 1000, + value: null, + }; + send(request); + if (kind === "early-credit") send({ type: "credit", id: 1, consumed: 1 }); + else { + await tick(); + if (kind === "duplicate-request") send(request); + if (kind === "early-release") send({ type: "release", id: 1 }); + if (kind === "duplicate-credit") { + send({ type: "credit", id: 1, consumed: 1 }); + send({ type: "credit", id: 1, consumed: 1 }); + } + } + await channel.closed; + assertEquals(channel.signal.aborted, true); + }); + } + + it("accepts credit crossing completion before the final release", async () => { + const { channel, send } = endpoint( + new Map([ + ["one", { + mode: "stream", + handle: async function* () { + yield 1; + }, + }], + ]), + ); + send({ type: "hello" }); + await channel.ready; + send({ + type: "request", + id: 1, + operation: "one", + mode: "stream", + timeoutMs: 1000, + value: null, + }); + await tick(); + send({ type: "credit", id: 1, consumed: 1 }); + send({ type: "release", id: 1 }); + await tick(); + assertEquals(channel.signal.aborted, false); + channel.close(); + }); + + it("closes when incoming concurrency exceeds the configured bound", async () => { + let input!: ReadableStreamDefaultController; + const channel = createExecutorChannel({ + binding, + maxConcurrentCalls: 1, + transport: { + readable: new ReadableStream({ + start(controller) { + input = controller; + }, + }), + writable: new WritableStream(), + }, + }); + input.enqueue(rawFrame(envelope({ type: "hello" }))); + await channel.ready; + for (const id of [1, 2]) { + input.enqueue( + rawFrame( + envelope({ + type: "request", + id, + operation: "missing", + mode: "unary", + timeoutMs: 1000, + value: null, + }, id), + ), + ); + } + assert((await channel.closed).message.includes("concurrent")); + }); + + it("bounds handshake time without sending operation input before readiness", async () => { + const written: ExecutorFrame[] = []; + const channel = createExecutorChannel({ + binding, + handshakeTimeoutMs: 10, + transport: { + readable: new ReadableStream(), + writable: new WritableStream({ + write(bytes) { + written.push(JSON.parse(new TextDecoder().decode(bytes.subarray(4)))); + }, + }), + }, + }); + await assertRejects(() => channel.request("echo", { synthetic: true }), Error, "handshake"); + assertEquals(written.map((frame) => frame.message.type), ["hello"]); + await channel.closed; + }); + + it("closes instead of accumulating writes behind a stalled transport", async () => { + let input!: ReadableStreamDefaultController; + const write = Promise.withResolvers(); + const channel = createExecutorChannel({ + binding, + operations: new Map([["large", { mode: "unary", handle: () => "x".repeat(900_000) }]]), + transport: { + readable: new ReadableStream({ + start(controller) { + input = controller; + }, + }), + writable: new WritableStream({ write: () => write.promise }), + }, + }); + input.enqueue(rawFrame(envelope({ type: "hello" }))); + await channel.ready; + for (let id = 1; id <= 10; id++) { + input.enqueue( + rawFrame( + envelope({ + type: "request", + id, + operation: "large", + mode: "unary", + timeoutMs: 1000, + value: null, + }, id), + ), + ); + } + try { + assert((await channel.closed).message.includes("write queue limit")); + } finally { + write.resolve(); + } + }); + + it("keeps a noncooperative handler counted and closes after its cancellation grace", async () => { + const finish = Promise.withResolvers(); + const { channel, send } = endpoint( + new Map([ + ["wait", { mode: "unary", handle: () => finish.promise }], + ]), + 10, + ); + send({ type: "hello" }); + await channel.ready; + send({ + type: "request", + id: 1, + operation: "wait", + mode: "unary", + timeoutMs: 1000, + value: null, + }); + await tick(); + send({ type: "cancel", id: 1 }); + await tick(); + send({ type: "release", id: 1 }); + try { + assert((await channel.closed).message.includes("handler cancellation")); + } finally { + finish.resolve(null); + } + }); + + it("bounds terminal release writes and rejects the call when release stalls", async () => { + let input!: ReadableStreamDefaultController; + const stalledWrite = Promise.withResolvers(); + const channel = createExecutorChannel({ + binding, + cancellationTimeoutMs: 10, + transport: { + readable: new ReadableStream({ + start(controller) { + input = controller; + }, + }), + writable: new WritableStream({ + write(bytes) { + const frame = JSON.parse(new TextDecoder().decode(bytes.subarray(4))) as ExecutorFrame; + if (frame.message.type === "release") return stalledWrite.promise; + }, + }), + }, + }); + input.enqueue(rawFrame(envelope({ type: "hello" }))); + await channel.ready; + const outcome = channel.request("echo", null).then(() => "resolved", () => "rejected"); + await tick(); + input.enqueue(rawFrame(envelope({ type: "data", id: 1, index: 0, value: null }, 1))); + input.enqueue(rawFrame(envelope({ type: "end", id: 1 }, 2))); + await new Promise((resolve) => setTimeout(resolve, 25)); + try { + assertEquals(channel.signal.aborted, true); + assertEquals(await outcome, "rejected"); + } finally { + channel.close(); + stalledWrite.resolve(); + } + }); + + it("bounds aggregate result payloads across otherwise valid stream windows", async () => { + const { channel, send } = endpoint(undefined, 100, 1024); + send({ type: "hello" }); + await channel.ready; + const streams = Array.from({ length: 3 }, () => channel.stream("items", null)); + await tick(); + for (let id = 1; id <= 3; id++) send({ type: "data", id, index: 0, value: "x".repeat(400) }); + assert((await channel.closed).message.includes("retained payload budget")); + for (const stream of streams) await assertRejects(() => stream.next()); + }); + + it("bounds aggregate incoming request payloads until handlers settle", async () => { + const signals: AbortSignal[] = []; + const { channel, send } = endpoint( + new Map([ + ["wait", { + mode: "unary", + handle: async (_, { signal }) => { + signals.push(signal); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return null; + }, + }], + ]), + 100, + 1024, + ); + send({ type: "hello" }); + await channel.ready; + for (let id = 1; id <= 3; id++) { + send({ + type: "request", + id, + operation: "wait", + mode: "unary", + timeoutMs: 1000, + value: "x".repeat(400), + }); + } + assert((await channel.closed).message.includes("retained payload budget")); + assertEquals(signals.length, 2); + assert(signals.every((signal) => signal.aborted)); + }); + + it("shares one retained payload budget between request and response directions", async () => { + const { channel, send } = endpoint( + new Map([ + ["wait", { + mode: "unary", + handle: async (_, { signal }) => { + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return null; + }, + }], + ]), + 100, + 1024, + ); + send({ type: "hello" }); + await channel.ready; + const stream = channel.stream("items", null); + await tick(); + send({ + type: "request", + id: 1, + operation: "wait", + mode: "unary", + timeoutMs: 1000, + value: "x".repeat(400), + }); + send({ type: "data", id: 1, index: 0, value: "x".repeat(400) }); + send({ type: "data", id: 1, index: 1, value: "x".repeat(400) }); + assert((await channel.closed).message.includes("retained payload budget")); + await assertRejects(() => stream.next()); + }); + + it("returns result budget on consumption and cancellation", async () => { + const { channel, send } = endpoint(undefined, 100, 1024); + send({ type: "hello" }); + await channel.ready; + const stream = channel.stream("items", null); + await tick(); + for (let index = 0; index < 6; index++) { + send({ type: "data", id: 1, index, value: "x".repeat(600) }); + assertEquals((await stream.next()).value, "x".repeat(600)); + } + send({ type: "data", id: 1, index: 6, value: "x".repeat(600) }); + await tick(); + const returning = stream.return!(); + send({ type: "end", id: 1 }); + await returning; + const next = channel.stream("items", null); + await tick(); + send({ type: "data", id: 2, index: 0, value: "x".repeat(600) }); + send({ type: "end", id: 2 }); + assertEquals((await next.next()).value, "x".repeat(600)); + assertEquals(channel.signal.aborted, false); + channel.close(); + }); + + it("releases cancelled requests before readiness without retaining callbacks or payload budget", async () => { + const channel = createExecutorChannel({ + binding, + maxConcurrentCalls: 1, + maxRetainedPayloadBytes: 1024, + transport: { readable: new ReadableStream(), writable: new WritableStream() }, + }); + try { + for (let index = 0; index < 20; index++) { + const controller = new AbortController(); + const result = channel.request("wait", "x".repeat(600), { signal: controller.signal }); + controller.abort(); + await assertRejects(() => result, Error, "cancelled"); + } + assertEquals(channel.signal.aborted, false); + } finally { + channel.close(); + } + }); +}); diff --git a/src/agent/executor/protocol.ts b/src/agent/executor/protocol.ts new file mode 100644 index 0000000000..1f4b99ce12 --- /dev/null +++ b/src/agent/executor/protocol.ts @@ -0,0 +1,145 @@ +import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; +import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; + +/** Internal protocol limits. The frame limit includes its four-byte length prefix. */ +export const EXECUTOR_PROTOCOL_VERSION = 1; +export const EXECUTOR_MAX_FRAME_BYTES = 1024 * 1024; +export const EXECUTOR_STREAM_WINDOW = 8; +export const EXECUTOR_MAX_CONCURRENT_CALLS = 32; +export const EXECUTOR_MAX_RETAINED_BYTES = 8 * 1024 * 1024; +export const EXECUTOR_MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000; + +export const getExecutorBindingSchema = defineSchema((v) => + v.object({ + allocationId: v.string().min(1).max(128), + generation: v.number().int().positive().max(Number.MAX_SAFE_INTEGER), + invocationId: v.string().min(1).max(128), + }).strict() +); + +/** An authenticated transport must be bound to these exact values before use. */ +export type ExecutorBinding = InferSchema>; + +export const getExecutorFrameSchema = defineSchema((v) => { + const id = v.number().int().positive().max(Number.MAX_SAFE_INTEGER); + const sequence = v.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); + return v.object({ + version: v.literal(EXECUTOR_PROTOCOL_VERSION), + binding: getExecutorBindingSchema(), + sequence, + message: v.discriminatedUnion("type", [ + v.object({ type: v.literal("hello") }).strict(), + v.object({ + type: v.literal("request"), + id, + operation: v.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/), + mode: v.enum(["unary", "stream"] as const), + timeoutMs: v.number().int().positive().max(EXECUTOR_MAX_TIMEOUT_MS), + value: getJsonValueSchema(), + }).strict(), + v.object({ type: v.literal("data"), id, index: sequence, value: getJsonValueSchema() }) + .strict(), + v.object({ + type: v.literal("end"), + id, + error: v.enum( + [ + "operation-not-found", + "mode-mismatch", + "operation-failed", + "cancelled", + "deadline", + ] as const, + ).optional(), + }).strict(), + v.object({ type: v.literal("credit"), id, consumed: id }).strict(), + v.object({ type: v.literal("cancel"), id }).strict(), + v.object({ type: v.literal("release"), id }).strict(), + v.object({ type: v.literal("released"), id }).strict(), + ]), + }).strict(); +}); + +export type ExecutorFrame = InferSchema>; +export type ExecutorMessage = ExecutorFrame["message"]; + +/** Distinguishes local protocol diagnostics from opaque transport exceptions. */ +export class ExecutorProtocolError extends Error {} + +/** Validate and snapshot before encoding so non-JSON values cannot be silently coerced. */ +export function encodeExecutorFrame(frame: ExecutorFrame): Uint8Array { + const snapshot = snapshotBoundedJsonValue(frame); + if (!snapshot.success || !getExecutorFrameSchema().safeParse(snapshot.value).success) { + throw new TypeError("Invalid executor frame"); + } + const payload = new TextEncoder().encode(JSON.stringify(snapshot.value)); + if (payload.byteLength > EXECUTOR_MAX_FRAME_BYTES - 4) { + throw new TypeError("Executor frame exceeds byte limit"); + } + const bytes = new Uint8Array(payload.byteLength + 4); + new DataView(bytes.buffer).setUint32(0, payload.byteLength); + bytes.set(payload, 4); + return bytes; +} + +/** + * Read split or coalesced frames with one bounded frame allocation. Transport + * adapters must emit chunks no larger than EXECUTOR_MAX_FRAME_BYTES. + */ +export async function* readExecutorFrames( + reader: ReadableStreamDefaultReader, +): AsyncGenerator { + const prefix = new Uint8Array(4); + let prefixOffset = 0; + let payload: Uint8Array | undefined; + let payloadOffset = 0; + const decoder = new TextDecoder("utf-8", { fatal: true }); + while (true) { + const { value: chunk, done } = await reader.read(); + if (done) { + if (prefixOffset || payload) throw new ExecutorProtocolError("Truncated executor frame"); + return; + } + if (!(chunk instanceof Uint8Array) || chunk.byteLength > EXECUTOR_MAX_FRAME_BYTES) { + throw new ExecutorProtocolError("Executor transport chunk exceeds byte limit"); + } + let offset = 0; + while (offset < chunk.byteLength) { + if (!payload) { + const size = Math.min(4 - prefixOffset, chunk.byteLength - offset); + prefix.set(chunk.subarray(offset, offset + size), prefixOffset); + offset += size; + prefixOffset += size; + if (prefixOffset < 4) continue; + const length = new DataView(prefix.buffer).getUint32(0); + if (!length || length > EXECUTOR_MAX_FRAME_BYTES - 4) { + throw new ExecutorProtocolError("Executor frame exceeds byte limit"); + } + payload = new Uint8Array(length); + prefixOffset = 0; + } + const size = Math.min(payload.byteLength - payloadOffset, chunk.byteLength - offset); + payload.set(chunk.subarray(offset, offset + size), payloadOffset); + payloadOffset += size; + offset += size; + if (payloadOffset === payload.byteLength) { + let decoded: unknown; + try { + decoded = JSON.parse(decoder.decode(payload)); + } catch { + throw new ExecutorProtocolError("Invalid executor frame encoding"); + } + if ( + decoded !== null && typeof decoded === "object" && "version" in decoded && + decoded.version !== EXECUTOR_PROTOCOL_VERSION + ) throw new ExecutorProtocolError("Unsupported executor protocol version"); + const result = getExecutorFrameSchema().safeParse(decoded); + if (!result.success) throw new ExecutorProtocolError("Invalid executor frame schema"); + payload = undefined; + payloadOffset = 0; + yield result.data; + } + } + } +} From 4ca9c78a1fd2a98e9b6d976072040744f577ed6f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 15:33:19 +0200 Subject: [PATCH 002/194] feat(agent): add authenticated executor model transport --- src/agent/executor/channel.test.ts | 29 + src/agent/executor/channel.ts | 2 +- .../hosted/executor-model-bridge.test.ts | 918 ++++++++++++++++++ src/agent/hosted/executor-model-bridge.ts | 289 ++++++ src/agent/hosted/executor-model-schema.ts | 294 ++++++ src/agent/hosted/executor-node-transport.ts | 378 ++++++++ .../agent/executor-model-channel-node.test.ts | 278 ++++++ .../agent/executor-node-transport.test.ts | 401 ++++++++ 8 files changed, 2588 insertions(+), 1 deletion(-) create mode 100644 src/agent/hosted/executor-model-bridge.test.ts create mode 100644 src/agent/hosted/executor-model-bridge.ts create mode 100644 src/agent/hosted/executor-model-schema.ts create mode 100644 src/agent/hosted/executor-node-transport.ts create mode 100644 tests/integration/agent/executor-model-channel-node.test.ts create mode 100644 tests/integration/agent/executor-node-transport.test.ts diff --git a/src/agent/executor/channel.test.ts b/src/agent/executor/channel.test.ts index 04c1ebb59c..bbbe700cba 100644 --- a/src/agent/executor/channel.test.ts +++ b/src/agent/executor/channel.test.ts @@ -356,4 +356,33 @@ describe("executor channel", () => { await receiver.closed; } }); + + it("does not schedule cancellation when returning a stream after channel closure", async () => { + const started = Promise.withResolvers(); + const { caller, receiver } = pair( + new Map([ + ["wait", { + mode: "stream", + handle: async function* (_, { signal }) { + started.resolve(); + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + yield null; + }, + }], + ]), + ); + try { + const stream = caller.stream("wait", null); + const pendingRead = assertRejects(() => stream.next(), Error, "Executor channel closed"); + await started.promise; + caller.close(); + await pendingRead; + await assertRejects(() => stream.return!(), Error, "Executor channel closed"); + } finally { + caller.close(); + await receiver.closed; + } + }); }); diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index ab745d4bc3..469fb9b861 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -356,7 +356,7 @@ class Channel implements ExecutorChannel { } #cancelOutgoing(call: OutgoingCall, reason: "cancelled" | "deadline"): void { - if (call.released || call.cancelled) return; + if (this.#error || call.released || call.cancelled) return; call.cancelled = true; call.error = new Error(`Executor call ${reason}`); this.#clearResults(call); diff --git a/src/agent/hosted/executor-model-bridge.test.ts b/src/agent/hosted/executor-model-bridge.test.ts new file mode 100644 index 0000000000..c4757dd68f --- /dev/null +++ b/src/agent/hosted/executor-model-bridge.test.ts @@ -0,0 +1,918 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createAnthropicProviderModel } from "@veryfront/ext-llm-anthropic"; +import { createGoogleProviderModel } from "@veryfront/ext-llm-google"; +import { observeFetchRequestInit, withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import { EXECUTOR_STREAM_WINDOW } from "../executor/protocol.ts"; +import { + createModelRuntimeResolverAbortScope, + resolveAgentModelTransport, + revokeModelRuntimeResolver, +} from "../runtime/model-transport.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "./executor-model-bridge.ts"; + +const modelId = "veryfront-cloud/openai/synthetic-model"; +const allowedModelIds = new Set([modelId]); +const prompt: ModelRuntimeCallOptions["prompt"] = [{ + role: "user", + content: [{ type: "text", text: "Synthetic prompt" }], +}]; + +function stubModel( + overrides: Partial> = {}, +): ModelRuntime { + return { + specificationVersion: "v2", + provider: "veryfront-cloud", + modelId: "synthetic-model", + modelProvider: "openai", + executionMode: "remote", + _generateViaStream: true, + runtimeCapabilities: { toolCalling: true, structuredOutput: ["json_schema"] }, + doGenerate: () => + Promise.resolve({ + content: [{ type: "text", text: "Synthetic answer" }], + finishReason: "stop", + usage: { inputTokens: 2, outputTokens: 3 }, + }), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: "Synthetic answer" }); + controller.close(); + }, + }), + }), + ...overrides, + }; +} + +function pair(operations: ReadonlyMap) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + }); + return { + caller, + broker, + async close() { + caller.close(); + await broker.closed; + }, + }; +} + +async function connected(model = stubModel()) { + const channels = pair( + createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: (id) => id === modelId ? model : undefined, + }), + ); + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const proxy = resolver(modelId)!; + return { ...channels, resolver, proxy }; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("executor managed model bridge", () => { + it("preserves metadata, readiness, neutral generation options, and data results", async () => { + let received: ModelRuntimeCallOptions | undefined; + let prepared = 0; + const model = stubModel({ + prepare: (signal) => { + assert(signal instanceof AbortSignal); + prepared++; + return Promise.resolve(); + }, + doGenerate: (options) => { + received = options; + return stubModel().doGenerate(options); + }, + }); + const channels = await connected(model); + try { + const options: ModelRuntimeCallOptions = { + prompt: [ + { + role: "system", + content: "Synthetic system", + providerOptions: { openai: { cache: true } }, + }, + ...prompt, + { + role: "user", + content: [{ + type: "image", + mediaType: "image/png", + url: "https://example.com/image.png", + }, { + type: "file", + mediaType: "text/plain", + url: "https://example.com/file.txt", + filename: "file.txt", + }], + }, + { + role: "assistant", + content: [{ + type: "reasoning", + text: "Synthetic reasoning", + signature: "synthetic", + redactedData: "synthetic", + }, { + type: "tool-call", + toolCallId: "call-test", + toolName: "lookup", + input: {}, + providerExecuted: true, + dynamic: true, + supportsDeferredResults: true, + }, { + type: "tool-result", + toolCallId: "call-test", + toolName: "lookup", + result: { ok: true }, + providerExecuted: true, + isError: false, + }], + providerToolCalls: [{ + toolCallId: "call-test", + toolName: "lookup", + input: {}, + supportsDeferredResults: true, + }], + providerMetadata: { openai: { itemId: "synthetic" } }, + }, + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "call-test", + toolName: "lookup", + output: { type: "json", value: { ok: true } }, + }], + }, + ], + maxOutputTokens: 100, + temperature: 0.5, + topP: 0.9, + topK: 5, + stopSequences: ["STOP"], + tools: [{ + type: "function", + name: "lookup", + description: "Synthetic tool", + inputSchema: { type: "object" }, + }, { + type: "provider", + name: "search", + id: "openai.web_search", + args: { searchContextSize: "low" }, + }], + toolChoice: { type: "tool", toolName: "lookup" }, + seed: 1, + presencePenalty: 0.5, + frequencyPenalty: 0.5, + providerOptions: { openai: { serviceTier: "auto" } }, + reasoning: { enabled: true, effort: "high", budgetTokens: 20 }, + includeRawChunks: false, + userId: "synthetic-user", + responseFormat: { + type: "json_schema", + name: "answer", + schema: { type: "object" }, + description: "Synthetic schema", + strict: true, + }, + abortSignal: new AbortController().signal, + }; + for ( + const key of [ + "specificationVersion", + "provider", + "modelId", + "modelProvider", + "executionMode", + "runtimeCapabilities", + "_generateViaStream", + ] as const + ) assertEquals(channels.proxy[key], model[key]); + await channels.proxy.prepare?.(); + assertEquals(prepared, 1); + assertEquals(await channels.proxy.doGenerate(options), await stubModel().doGenerate(options)); + const { abortSignal: originalSignal, ...expected } = options; + assert(received?.abortSignal instanceof AbortSignal); + assert(received.abortSignal !== originalSignal); + const { abortSignal: _signal, ...actual } = received; + assertEquals(actual, expected); + } finally { + await channels.close(); + } + }); + + it("uses the synchronous runtime resolver and prevents unknown managed model fallback", async () => { + const channels = await connected(); + let projectCalls = 0; + try { + const input = { + agentId: "synthetic-agent", + config: { + system: "Synthetic system", + model: modelId, + resolveModelTransport: () => { + projectCalls++; + return Promise.resolve({ model: stubModel() }); + }, + }, + context: undefined, + modelOverride: undefined, + mode: "generate" as const, + resolveModelRuntime: channels.resolver, + }; + const resolved = await resolveAgentModelTransport(input); + assertEquals(resolved.languageModel, channels.proxy); + await assertRejects( + () => + resolveAgentModelTransport({ ...input, modelOverride: "veryfront-cloud/openai/unknown" }), + Error, + "Managed model is not allowed", + ); + assertEquals(projectCalls, 0); + assertEquals(channels.resolver("project/custom"), undefined); + } finally { + await channels.close(); + } + }); + + it("revokes retained proxies before project abort listeners can make another call", async () => { + let calls = 0; + const channels = await connected(stubModel({ + prepare: () => { + calls++; + return Promise.resolve(); + }, + doGenerate: () => { + calls++; + return Promise.resolve({}); + }, + doStream: () => { + calls++; + return stubModel().doStream({ prompt }); + }, + })); + try { + const scope = createModelRuntimeResolverAbortScope(channels.resolver); + let retainedCall: PromiseLike | undefined; + scope.signal.addEventListener("abort", () => { + retainedCall = channels.proxy.doGenerate({ prompt }); + }, { once: true }); + scope.abort(); + await assertRejects( + async () => await retainedCall, + TypeError, + "Managed model resolver is revoked", + ); + await assertRejects( + async () => await channels.proxy.prepare?.(), + TypeError, + "Managed model resolver is revoked", + ); + await assertRejects( + async () => await channels.proxy.doStream({ prompt }), + TypeError, + "Managed model resolver is revoked", + ); + assertThrows( + () => channels.resolver(modelId), + TypeError, + "Managed model resolver is revoked", + ); + assertEquals(calls, 0); + scope.dispose(); + } finally { + await channels.close(); + } + }); + + it("resolver revocation cancels active generation and an idle stream", async () => { + const started = Promise.withResolvers(); + let signal: AbortSignal | undefined; + const channels = await connected(stubModel({ + doGenerate: (options) => { + signal = options.abortSignal; + started.resolve(); + return new Promise((_resolve, reject) => + signal!.addEventListener("abort", () => reject(new Error("Synthetic private detail")), { + once: true, + }) + ); + }, + })); + try { + const pending = channels.proxy.doGenerate({ prompt }); + await started.promise; + revokeModelRuntimeResolver(channels.resolver); + await assertRejects(async () => await pending, Error, "cancelled"); + assertEquals(signal?.aborted, true); + } finally { + await channels.close(); + } + let cancelled = false; + const streaming = await connected(stubModel({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }), + })); + try { + const { stream } = await streaming.proxy.doStream({ prompt }); + revokeModelRuntimeResolver(streaming.resolver); + await assertRejects(() => stream.getReader().read(), Error, "cancelled"); + await tick(); + assertEquals(cancelled, true); + } finally { + await streaming.close(); + } + }); + + it("rejects unknown models and invalid options before broker resolution or generation", async () => { + let resolutions = 0; + let generations = 0; + const channels = pair(createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => { + resolutions++; + return stubModel({ + doGenerate: () => { + generations++; + return Promise.resolve({}); + }, + }); + }, + })); + try { + for ( + const options of [ + { prompt, headers: { Authorization: "synthetic" } }, + { prompt, abortSignal: {} }, + { prompt, url: "https://example.com" }, + { prompt, reasoning: { effort: "invalid" } }, + { prompt: [{ role: "system", content: 1 }] }, + { prompt, tools: [{ type: "function", name: "bad", inputSchema: {}, execute: "code" }] }, + { prompt, providerOptions: { openai: { headers: { authorization: "synthetic" } } } }, + { prompt, providerOptions: { openai: { baseURL: "https://example.com" } } }, + { prompt, providerOptions: { anthropic: { model: "synthetic-other-model" } } }, + { prompt, providerOptions: { "veryfront-cloud": { model: "synthetic-other-model" } } }, + { + prompt, + providerOptions: { "openai-compatible": { model_id: "synthetic-other-model" } }, + }, + { prompt, providerOptions: { google: { deploymentName: "synthetic-other-model" } } }, + { prompt, responseFormat: { type: "json_schema", name: "missing-schema" } }, + ] + ) { + await assertRejects( + () => + channels.caller.request("model.generate", { modelId, options } as unknown as JsonValue), + Error, + "operation-failed", + ); + } + await assertRejects( + () => + channels.caller.request( + "model.generate", + { + modelId: "veryfront-cloud/openai/unknown", + options: { prompt }, + } as unknown as JsonValue, + ), + Error, + "operation-failed", + ); + assertEquals(resolutions, 0); + assertEquals(generations, 0); + } finally { + await channels.close(); + } + }); + + it("keeps the allowed model pinned through the first-party Anthropic request builder", async () => { + const allowedId = "veryfront-cloud/anthropic/claude-haiku-4-5"; + const allowedIds = new Set([allowedId]); + const requestedModels: unknown[] = []; + const mockFetch: typeof fetch = (_input, init) => { + const { body } = observeFetchRequestInit(init); + assert(typeof body === "string"); + requestedModels.push(JSON.parse(body).model); + return Promise.resolve( + new Response( + JSON.stringify({ + content: [{ type: "text", text: "Synthetic answer" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }; + await withMockFetch(mockFetch, async () => { + const model = createAnthropicProviderModel("claude-haiku-4-5", { + credential: "", + name: "veryfront-cloud", + baseURL: "https://example.com/v1", + fetch: mockFetch, + }); + const channels = pair( + createExecutorModelBroker({ + allowedModelIds: allowedIds, + resolveModelRuntime: () => model, + }), + ); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds: allowedIds, + }); + const proxy = resolver(allowedId)!; + const result = await proxy.doGenerate({ + prompt, + maxOutputTokens: 10, + providerOptions: { anthropic: { temperature: 0.2 } }, + }); + assertEquals(result.content, [{ type: "text", text: "Synthetic answer" }]); + assertEquals(requestedModels, ["claude-haiku-4-5"]); + for (const bucket of ["anthropic", "veryfront-cloud"]) { + await assertRejects( + () => + channels.caller.request("model.generate", { + modelId: allowedId, + options: { prompt, providerOptions: { [bucket]: { model: "claude-opus-4-6" } } }, + } as unknown as JsonValue), + Error, + "operation-failed", + ); + await assertRejects( + async () => + await proxy.doGenerate({ + prompt, + providerOptions: { [bucket]: { model: "claude-opus-4-6" } }, + }), + TypeError, + ); + } + assertEquals(requestedModels, ["claude-haiku-4-5"]); + } finally { + await channels.close(); + } + }); + }); + + it("preserves schema property names through the first-party Google request builder", async () => { + const allowedId = "veryfront-cloud/google/gemini-synthetic"; + const allowedIds = new Set([allowedId]); + const responseSchema = { + type: "OBJECT", + properties: { + url: { type: "STRING" }, + auth: { type: "STRING" }, + model: { type: "STRING" }, + headers: { type: "STRING" }, + }, + }; + const schemas: unknown[] = []; + const mockFetch: typeof fetch = (_input, init) => { + const { body } = observeFetchRequestInit(init); + assert(typeof body === "string"); + schemas.push(JSON.parse(body).generationConfig.responseSchema); + return Promise.resolve( + new Response( + JSON.stringify({ + candidates: [{ + content: { parts: [{ text: "Synthetic answer" }] }, + finishReason: "STOP", + }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }; + await withMockFetch(mockFetch, async () => { + const model = createGoogleProviderModel("gemini-synthetic", { + credential: "", + name: "veryfront-cloud", + baseURL: "https://example.com/v1", + fetch: mockFetch, + }); + const channels = pair( + createExecutorModelBroker({ + allowedModelIds: allowedIds, + resolveModelRuntime: () => model, + }), + ); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds: allowedIds, + }); + const proxy = resolver(allowedId)!; + await proxy.doGenerate({ + prompt, + providerOptions: { + google: { generationConfig: { responseMimeType: "application/json", responseSchema } }, + }, + }); + assertEquals(schemas, [responseSchema]); + for (const field of ["url", "auth", "model", "headers"]) { + await assertRejects( + () => + channels.caller.request("model.generate", { + modelId: allowedId, + options: { prompt, providerOptions: { google: { [field]: "synthetic-override" } } }, + } as unknown as JsonValue), + Error, + "operation-failed", + ); + } + assertEquals(schemas, [responseSchema]); + } finally { + await channels.close(); + } + }); + }); + + it("forbids proxy header overrides and accepts omitted optional values", async () => { + const channels = await connected(); + try { + await assertRejects( + async () => await channels.proxy.doGenerate({ prompt, headers: new Headers() }), + TypeError, + ); + await channels.proxy.doGenerate({ + prompt, + maxOutputTokens: undefined, + reasoning: { enabled: true, effort: undefined }, + }); + } finally { + await channels.close(); + } + }); + + it("fails closed without a resolver or a complete exact metadata list", async () => { + assertThrows( + () => createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: undefined }), + TypeError, + ); + const channels = pair( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => undefined }), + ); + try { + await assertRejects( + () => createExecutorModelRuntimeResolver({ channel: channels.caller, allowedModelIds }), + Error, + "operation-failed", + ); + } finally { + await channels.close(); + } + for ( + const descriptors of [[], [{ id: modelId }, { id: modelId }], [{ + id: "veryfront-cloud/openai/extra", + }]] + ) { + const malformed = pair( + new Map([["model.metadata", { mode: "unary", handle: () => descriptors }]]), + ); + try { + await assertRejects( + () => createExecutorModelRuntimeResolver({ channel: malformed.caller, allowedModelIds }), + TypeError, + ); + } finally { + await malformed.close(); + } + } + }); + + it("preserves stream warnings and chunks without reading past bounded credit", async () => { + let pulls = 0; + let cancelled = false; + let signal: AbortSignal | undefined; + const channels = await connected(stubModel({ + doStream: (options) => { + signal = options.abortSignal; + return Promise.resolve({ + warnings: [{ type: "other", message: "Synthetic warning" }], + stream: new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue({ type: "text-delta", delta: String(pulls) }); + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }); + }, + })); + try { + const result = await channels.proxy.doStream({ prompt }); + assertEquals(result.warnings, [{ type: "other", message: "Synthetic warning" }]); + await tick(); + assert(pulls <= EXECUTOR_STREAM_WINDOW); + const reader = result.stream.getReader(); + assertEquals((await reader.read()).value, { type: "text-delta", delta: "1" }); + await reader.cancel(); + assertEquals(cancelled, true); + assertEquals(signal?.aborted, true); + } finally { + await channels.close(); + } + }); + + it("propagates cancellation during generation and an idle stream", async () => { + const started = Promise.withResolvers(); + let signal: AbortSignal | undefined; + const channels = await connected(stubModel({ + doGenerate: (options) => { + signal = options.abortSignal; + started.resolve(); + return new Promise((_resolve, reject) => + signal!.addEventListener( + "abort", + () => reject(new Error("Synthetic private upstream failure")), + { once: true }, + ) + ); + }, + })); + try { + const controller = new AbortController(); + const call = channels.proxy.doGenerate({ prompt, abortSignal: controller.signal }); + await started.promise; + controller.abort(); + await assertRejects(async () => await call, Error, "cancelled"); + assertEquals(signal?.aborted, true); + } finally { + await channels.close(); + } + let cancelled = false; + const streaming = await connected( + stubModel({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }), + }), + ); + try { + const controller = new AbortController(); + const { stream } = await streaming.proxy.doStream({ prompt, abortSignal: controller.signal }); + controller.abort(); + await assertRejects(() => stream.getReader().read(), Error, "cancelled"); + await tick(); + assertEquals(cancelled, true); + } finally { + await streaming.close(); + } + }); + + it("finishes a stream and keeps transport response fields out of generation results", async () => { + const channels = await connected(stubModel({ + doGenerate: () => + Promise.resolve({ + content: [{ type: "text", text: "Synthetic answer" }], + response: { headers: { "x-internal": "Synthetic transport detail" } }, + rawResponse: { error: "Synthetic private detail" }, + }), + })); + try { + assertEquals(await channels.proxy.doGenerate({ prompt }), { + content: [{ type: "text", text: "Synthetic answer" }], + }); + const { stream, warnings } = await channels.proxy.doStream({ prompt }); + assertEquals(warnings, undefined); + const reader = stream.getReader(); + assertEquals(await reader.read(), { + done: false, + value: { type: "text-delta", delta: "Synthetic answer" }, + }); + assertEquals(await reader.read(), { done: true, value: undefined }); + } finally { + await channels.close(); + } + }); + + it("cancels preparation and rejects an already cancelled generation before dispatch", async () => { + const started = Promise.withResolvers(); + let preparedSignal: AbortSignal | undefined; + let generations = 0; + const channels = await connected(stubModel({ + prepare: (signal) => { + preparedSignal = signal; + started.resolve(); + return new Promise((_resolve, reject) => + signal!.addEventListener( + "abort", + () => reject(new Error("Synthetic preparation detail")), + { once: true }, + ) + ); + }, + doGenerate: () => { + generations++; + return Promise.resolve({}); + }, + })); + try { + const controller = new AbortController(); + const call = channels.proxy.prepare!(controller.signal); + await started.promise; + controller.abort(); + await assertRejects(async () => await call, Error, "cancelled"); + assertEquals(preparedSignal?.aborted, true); + await assertRejects( + async () => await channels.proxy.doGenerate({ prompt, abortSignal: controller.signal }), + Error, + "cancelled", + ); + assertEquals(generations, 0); + } finally { + await channels.close(); + } + }); + + it("bounds input collections and oversized output with fixed failures", async () => { + const channels = await connected(stubModel({ + doGenerate: () => Promise.resolve({ content: ["x".repeat(600_000), "y".repeat(600_000)] }), + })); + try { + await assertRejects( + () => + channels.caller.request("model.generate", { + modelId, + options: { + prompt: Array.from( + { length: 1001 }, + () => ({ role: "system", content: "synthetic" }), + ), + }, + }), + Error, + "operation-failed", + ); + const error = await assertRejects( + async () => await channels.proxy.doGenerate({ prompt }), + Error, + ); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await channels.close(); + } + }); + + it("preserves recoverable provider-tool errors and subsequent text and usage", async () => { + for ( + const toolError of [ + { + type: "tool-error", + toolCallId: "search-test", + toolName: "web_search", + error: [{ type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }], + isError: true, + providerExecuted: true, + }, + { + type: "tool-error", + toolCallId: "code-test", + toolName: "code_execution", + error: { outcome: "OUTCOME_FAILED", output: "Synthetic tool failure" }, + isError: true, + providerExecuted: true, + }, + ] + ) { + const parts = [toolError, { type: "text-delta", delta: "Synthetic continuation" }, { + type: "finish", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 2 }, + }]; + const channels = await connected(stubModel({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(part); + controller.close(); + }, + }), + }), + })); + try { + const { stream } = await channels.proxy.doStream({ prompt }); + const reader = stream.getReader(); + const received: unknown[] = []; + while (true) { + const next = await reader.read(); + if (next.done) break; + received.push(next.value); + } + assertEquals(received, parts); + } finally { + await channels.close(); + } + } + }); + + it("keeps upstream errors and non-data results out of the wire", async () => { + for ( + const model of [ + stubModel({ doGenerate: () => Promise.reject(new Error("Synthetic private error body")) }), + stubModel({ + doGenerate: () => + Promise.resolve({ content: [new Error("Synthetic private error body")] }), + }), + ] + ) { + const channels = await connected(model); + try { + const error = await assertRejects( + async () => await channels.proxy.doGenerate({ prompt }), + Error, + "operation-failed", + ); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await channels.close(); + } + } + for ( + const part of [ + { type: "error", error: "Synthetic private error body" }, + { type: "raw", rawValue: { error: { message: "Synthetic private error body" } } }, + { type: "raw", rawValue: { type: "tool-error", error: "Synthetic raw error body" } }, + ] + ) { + const channels = await connected(stubModel({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + controller.enqueue(part); + controller.close(); + }, + }), + }), + })); + try { + const error = await assertRejects( + async () => { + const { stream } = await channels.proxy.doStream({ prompt }); + await stream.getReader().read(); + }, + Error, + "operation-failed", + ); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await channels.close(); + } + } + }); +}); diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts new file mode 100644 index 0000000000..1fd149ebae --- /dev/null +++ b/src/agent/hosted/executor-model-bridge.ts @@ -0,0 +1,289 @@ +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { + ExecutorChannel, + ExecutorOperation, + ExecutorOperationContext, +} from "../executor/channel.ts"; +import { + type AgentModelRuntimeResolver, + registerModelRuntimeResolverRevoker, +} from "../runtime/model-transport.ts"; +import { + executorModelIds, + executorModelJson, + type ExecutorModelMetadata, + getExecutorModelCallSchema, + getExecutorModelEmptySchema, + getExecutorModelGenerateResultSchema, + getExecutorModelMetadataSchema, + getExecutorModelOptionsSchema, + getExecutorModelRequestSchema, + getExecutorModelStreamFrameSchema, + parseExecutorModelData, +} from "./executor-model-schema.ts"; + +/** + * Construct only in trusted ingress. The resolver closes over ingress-owned + * authority; project discovery, extension registries, and credentials are not + * part of this operation interface. + */ +export function createExecutorModelBroker(options: { + resolveModelRuntime: AgentModelRuntimeResolver | undefined; + allowedModelIds: ReadonlySet; +}): ReadonlyMap { + const resolve = options.resolveModelRuntime; + if (typeof resolve !== "function") throw new TypeError("Managed model resolver is required"); + const allowed = executorModelIds(options.allowedModelIds); + const models = new Map(); + const getModel = (id: string): ModelRuntime => { + if (!allowed.has(id)) throw new TypeError("Managed model is not allowed"); + const cached = models.get(id); + if (cached) return cached; + const model = resolve(id); + if (!model) throw new TypeError("Managed model is unavailable"); + models.set(id, model); + return model; + }; + const parseCall = (input: JsonValue, context: ExecutorOperationContext) => { + const call = parseExecutorModelData(getExecutorModelCallSchema(), input); + context.signal.throwIfAborted(); + return { + model: getModel(call.modelId), + options: { ...call.options, abortSignal: context.signal } satisfies ModelRuntimeCallOptions, + }; + }; + return new Map([ + ["model.metadata", { + mode: "unary", + handle(input, context) { + parseExecutorModelData(getExecutorModelEmptySchema(), input); + context.signal.throwIfAborted(); + const metadata = [...allowed].map((id) => modelMetadata(id, getModel(id))); + return executorModelJson( + parseExecutorModelData(getExecutorModelMetadataSchema(), executorModelJson(metadata)), + ); + }, + }], + ["model.prepare", { + mode: "unary", + async handle(input, context) { + const { modelId } = parseExecutorModelData(getExecutorModelRequestSchema(), input); + context.signal.throwIfAborted(); + await getModel(modelId).prepare?.(context.signal); + return null; + }, + }], + ["model.generate", { + mode: "unary", + async handle(input, context) { + const call = parseCall(input, context); + const result = await call.model.doGenerate(call.options); + // Only the neutral result fields leave the broker. Native request and + // response objects, headers, and transport diagnostics are never copied. + const data = executorModelJson({ + content: result.content, + finishReason: result.finishReason, + usage: result.usage, + warnings: result.warnings, + providerMetadata: result.providerMetadata, + }); + return executorModelJson( + parseExecutorModelData(getExecutorModelGenerateResultSchema(), data), + ); + }, + }], + ["model.stream", { + mode: "stream", + async *handle(input, context) { + const call = parseCall(input, context); + const result = await call.model.doStream(call.options); + const reader = result.stream.getReader(); + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + context.signal.addEventListener("abort", cancel, { once: true }); + let complete = false; + try { + if (context.signal.aborted) { + cancel(); + context.signal.throwIfAborted(); + } + yield executorModelJson( + parseExecutorModelData( + getExecutorModelStreamFrameSchema(), + executorModelJson({ type: "start", warnings: result.warnings }), + ), + ); + while (true) { + context.signal.throwIfAborted(); + const next = await reader.read(); + context.signal.throwIfAborted(); + if (next.done) { + complete = true; + return; + } + const value = executorModelJson(next.value); + rejectStreamError(value); + yield { type: "chunk", value }; + } + } finally { + context.signal.removeEventListener("abort", cancel); + if (!complete) await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + }, + }], + ]); +} + +function modelMetadata(id: string, model: ModelRuntime) { + return { + id, + specificationVersion: model.specificationVersion, + provider: model.provider, + modelId: model.modelId, + modelProvider: model.modelProvider, + executionMode: model.executionMode, + runtimeCapabilities: model.runtimeCapabilities, + _generateViaStream: model._generateViaStream, + }; +} + +function rejectStreamError(value: JsonValue, rawEnvelope = false): void { + if (value === null || typeof value !== "object" || Array.isArray(value)) return; + // First-party provider-tool failures are normalized results; inference can + // continue with text and final usage. Raw provider errors remain fatal. + if (value.type === "tool-error" && !rawEnvelope) return; + if (value.type === "error" || Object.hasOwn(value, "error")) { + throw new TypeError("Managed model stream failed"); + } + // Raw stream support still must not expose a provider's error envelope. + if (value.type === "raw" && value.rawValue !== undefined) rejectStreamError(value.rawValue, true); +} + +/** + * Fetch and validate the complete allowlisted metadata before project runtime + * preparation. The returned resolver is synchronous, as required by the agent + * runtime. Unknown managed IDs throw so runtime resolution cannot fall back to + * project extensions or the global model registry. + */ +export async function createExecutorModelRuntimeResolver(options: { + channel: ExecutorChannel; + allowedModelIds: ReadonlySet; + signal?: AbortSignal; +}): Promise { + const allowed = executorModelIds(options.allowedModelIds); + const data = await options.channel.request("model.metadata", {}, { signal: options.signal }); + const metadata = parseExecutorModelData(getExecutorModelMetadataSchema(), data); + const ids = new Set(metadata.map((entry) => entry.id)); + if ( + metadata.length !== allowed.size || ids.size !== allowed.size || + [...ids].some((id) => !allowed.has(id)) + ) { + throw new TypeError("Invalid managed model metadata"); + } + const authority = new AbortController(); + let revoked = false; + const assertActive = () => { + if (revoked) throw new TypeError("Managed model resolver is revoked"); + }; + const models = new Map( + metadata.map(( + entry, + ) => [ + entry.id, + createExecutorModelRuntime(options.channel, entry, authority.signal, assertActive), + ]), + ); + const resolver: AgentModelRuntimeResolver = (id) => { + const model = models.get(id); + if (model || id.startsWith("veryfront-cloud/")) assertActive(); + if (model) return model; + if (id.startsWith("veryfront-cloud/")) throw new TypeError("Managed model is not allowed"); + return undefined; + }; + // Revoke before abort dispatch, independently of mutable executor abort APIs. + // The signal cooperatively cancels in-flight calls. Broker-owned revocation + // and channel closure remain the authority fence for a compromised executor. + registerModelRuntimeResolverRevoker(resolver, () => { + revoked = true; + authority.abort(); + }); + return resolver; +} + +function createExecutorModelRuntime( + channel: ExecutorChannel, + descriptor: ExecutorModelMetadata, + authoritySignal: AbortSignal, + assertActive: () => void, +): ModelRuntime { + const { id, ...metadata } = descriptor; + const withAuthority = (signal?: AbortSignal) => + signal ? AbortSignal.any([authoritySignal, signal]) : authoritySignal; + const makeCall = (options: ModelRuntimeCallOptions) => { + assertActive(); + if (options.headers !== undefined) { + throw new TypeError("Managed model header overrides are forbidden"); + } + const { abortSignal, headers: _headers, ...neutral } = options; + const data = executorModelJson(neutral); + parseExecutorModelData(getExecutorModelOptionsSchema(), data); + return { input: { modelId: id, options: data }, signal: withAuthority(abortSignal) }; + }; + return Object.freeze({ + ...metadata, + async prepare(signal?: AbortSignal) { + assertActive(); + const combinedSignal = withAuthority(signal); + assertActive(); + const result = await channel.request("model.prepare", { modelId: id }, { + signal: combinedSignal, + }); + if (result !== null) throw new TypeError("Invalid managed model preparation result"); + }, + async doGenerate(options: ModelRuntimeCallOptions) { + const call = makeCall(options); + assertActive(); + const result = await channel.request("model.generate", call.input, { signal: call.signal }); + return parseExecutorModelData(getExecutorModelGenerateResultSchema(), result); + }, + async doStream(options: ModelRuntimeCallOptions) { + const call = makeCall(options); + assertActive(); + const iterator = channel.stream("model.stream", call.input, { signal: call.signal }); + try { + const first = await iterator.next(); + if (first.done) throw new TypeError("Managed model stream start is missing"); + const start = parseExecutorModelData(getExecutorModelStreamFrameSchema(), first.value); + if (start.type !== "start") throw new TypeError("Invalid managed model stream start"); + const stream = new ReadableStream({ + async pull(controller) { + try { + const next = await iterator.next(); + if (next.done) { + controller.close(); + return; + } + const frame = parseExecutorModelData(getExecutorModelStreamFrameSchema(), next.value); + if (frame.type !== "chunk") throw new TypeError("Invalid managed model stream chunk"); + rejectStreamError(frame.value); + controller.enqueue(frame.value); + } catch (error) { + controller.error(error); + await iterator.return?.(); + } + }, + async cancel() { + await iterator.return?.(); + }, + }, { highWaterMark: 0 }); + return { stream, ...(start.warnings === undefined ? {} : { warnings: start.warnings }) }; + } catch (error) { + await iterator.return?.(); + throw error; + } + }, + }); +} diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts new file mode 100644 index 0000000000..9273d660c7 --- /dev/null +++ b/src/agent/hosted/executor-model-schema.ts @@ -0,0 +1,294 @@ +import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; +import { defineSchema, getJsonValueSchema, type JsonValue } from "#veryfront/schemas/index.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; + +const MAX_MODELS = 128; +const MAX_ITEMS = 1000; + +const getModelIdSchema = defineSchema((v) => v.string().min(1).max(256)); + +// First-party builders shallow-merge canonical and gateway provider buckets +// into upstream request bodies. Model selection belongs to the broker's +// allowlist, including when a provider-name alias selects the bucket. This +// reservation covers the supported request contracts, not arbitrary future +// provider transports; new builders must keep model selection broker-owned. +const BROKER_MODEL_SELECTION_FIELDS = new Set([ + "model", + "modelid", + "modelname", + "modelprovider", + "provider", + "deployment", + "deploymentid", + "deploymentname", + "engine", +]); + +// These are configuration fields at each provider bucket root. Nested schemas, +// tool data, and replay data may use the same names as ordinary properties. +const BROKER_TRANSPORT_FIELDS = new Set([ + "headers", + "authorization", + "auth", + "apikey", + "apitoken", + "authtoken", + "credential", + "credentials", + "baseurl", + "url", + "endpoint", + "fetch", + "signal", + "abortsignal", +]); + +const getProviderOptionsSchema = defineSchema((v) => + v.record(v.string(), getJsonValueSchema()).refine((value) => { + for (const bucket of Object.values(value)) { + if (bucket === null || typeof bucket !== "object" || Array.isArray(bucket)) continue; + for (const field of Object.keys(bucket)) { + const normalized = field.replace(/[-_]/g, "").toLowerCase(); + if ( + BROKER_MODEL_SELECTION_FIELDS.has(normalized) || BROKER_TRANSPORT_FIELDS.has(normalized) + ) { + return false; + } + } + } + return true; + }, "Managed model request overrides are forbidden") +); + +/** The provider-neutral call contract, excluding headers and local cancellation objects. */ +export const getExecutorModelOptionsSchema = defineSchema((v) => { + const json = getJsonValueSchema(); + const text = v.object({ type: v.literal("text"), text: v.string() }).strict(); + const toolIdentity = { toolCallId: v.string(), toolName: v.string() }; + const providerFlags = { + dynamic: v.boolean().optional(), + supportsDeferredResults: v.boolean().optional(), + }; + const prompt = v.discriminatedUnion("role", [ + v.object({ + role: v.literal("system"), + content: v.string(), + providerOptions: getProviderOptionsSchema().optional(), + }).strict(), + v.object({ + role: v.literal("user"), + content: v.array(v.union([ + text, + v.object({ + type: v.enum(["image", "file"] as const), + mediaType: v.string(), + url: v.string(), + filename: v.string().optional(), + }).strict(), + ])).max(MAX_ITEMS), + }).strict(), + v.object({ + role: v.literal("assistant"), + content: v.array(v.discriminatedUnion("type", [ + text, + v.object({ + type: v.literal("reasoning"), + text: v.string().optional(), + signature: v.string().optional(), + redactedData: v.string().optional(), + }).strict(), + v.object({ + type: v.literal("tool-call"), + ...toolIdentity, + input: json, + providerExecuted: v.boolean().optional(), + ...providerFlags, + }).strict(), + v.object({ + type: v.literal("tool-result"), + ...toolIdentity, + result: json, + providerExecuted: v.literal(true), + isError: v.boolean().optional(), + ...providerFlags, + }).strict(), + ])).max(MAX_ITEMS), + providerToolCalls: v.array( + v.object({ ...toolIdentity, input: json, supportsDeferredResults: v.boolean().optional() }) + .strict(), + ).max(MAX_ITEMS).optional(), + providerMetadata: v.record(v.string(), json).optional(), + }).strict(), + v.object({ + role: v.literal("tool"), + content: v.array( + v.object({ + type: v.literal("tool-result"), + ...toolIdentity, + output: v.object({ type: v.literal("json"), value: json }).strict(), + }).strict(), + ).max(MAX_ITEMS), + }).strict(), + ]); + return v.object({ + prompt: v.array(prompt).max(MAX_ITEMS), + maxOutputTokens: v.number().int().positive().optional(), + temperature: v.number().optional(), + topP: v.number().optional(), + topK: v.number().int().nonnegative().optional(), + stopSequences: v.array(v.string()).max(MAX_ITEMS).optional(), + tools: v.array(v.discriminatedUnion("type", [ + v.object({ + type: v.literal("function"), + name: v.string(), + description: v.string().optional(), + inputSchema: json, + }).strict(), + v.object({ + type: v.literal("provider"), + name: v.string(), + id: v.string().regex(/^.+\..+$/).transform((id) => id as `${string}.${string}`), + args: v.record(v.string(), json), + }).strict(), + ])).max(MAX_ITEMS).optional(), + toolChoice: json.optional(), + seed: v.number().int().optional(), + presencePenalty: v.number().optional(), + frequencyPenalty: v.number().optional(), + providerOptions: getProviderOptionsSchema().optional(), + reasoning: v.object({ + enabled: v.boolean().optional(), + effort: v.enum(["low", "medium", "high", "max"] as const).optional(), + budgetTokens: v.number().int().nonnegative().optional(), + }).strict().optional(), + includeRawChunks: v.boolean().optional(), + userId: v.string().optional(), + responseFormat: v.discriminatedUnion("type", [ + v.object({ type: v.literal("text") }).strict(), + v.object({ type: v.literal("json") }).strict(), + v.object({ + type: v.literal("json_schema"), + name: v.string(), + schema: json, + description: v.string().optional(), + strict: v.boolean().optional(), + }).strict(), + ]).optional(), + }).strict(); +}); + +export const getExecutorModelRequestSchema = defineSchema((v) => + v.object({ modelId: getModelIdSchema() }).strict() +); + +export const getExecutorModelCallSchema = defineSchema((v) => + v.object({ modelId: getModelIdSchema(), options: getExecutorModelOptionsSchema() }).strict() +); + +export const getExecutorModelMetadataSchema = defineSchema((v) => + v.array( + v.object({ + id: getModelIdSchema(), + specificationVersion: v.string().max(128).optional(), + provider: v.string().max(256).optional(), + modelId: getModelIdSchema().optional(), + modelProvider: v.string().max(256).optional(), + executionMode: v.enum(["remote", "server-local"] as const).optional(), + runtimeCapabilities: v.object({ + toolCalling: v.boolean().optional(), + structuredOutput: v.union([ + v.boolean(), + v.array(v.enum(["json", "json_schema"] as const)).max(2), + ]).optional(), + }).strict().optional(), + _generateViaStream: v.boolean().optional(), + }).strict(), + ).max(MAX_MODELS) +); + +export type ExecutorModelMetadata = InferSchema< + ReturnType +>[number]; + +export const getExecutorModelGenerateResultSchema = defineSchema((v) => + v.object({ + content: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), + finishReason: getJsonValueSchema().optional(), + usage: getJsonValueSchema().optional(), + warnings: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), + providerMetadata: v.record(v.string(), getJsonValueSchema()).optional(), + }).strict() +); + +export const getExecutorModelStreamFrameSchema = defineSchema((v) => + v.discriminatedUnion("type", [ + v.object({ + type: v.literal("start"), + warnings: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), + }).strict(), + v.object({ type: v.literal("chunk"), value: getJsonValueSchema() }).strict(), + ]) +); + +export const getExecutorModelEmptySchema = defineSchema((v) => v.object({}).strict()); + +/** Schema diagnostics never expose rejected model inputs or provider output. */ +export function parseExecutorModelData(schema: Schema, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) throw new TypeError("Invalid managed model data"); + return result.data; +} + +export function executorModelIds(ids: ReadonlySet): Set { + if (!(ids instanceof Set) || ids.size === 0 || ids.size > MAX_MODELS) { + throw new TypeError("Invalid managed model allowlist"); + } + for (const id of ids) parseExecutorModelData(getModelIdSchema(), id); + return new Set(ids); +} + +/** + * Runtime structs commonly contain optional properties with undefined values. + * Omit those properties, but reject executable, cyclic, or non-JSON values. + * The final shared snapshot enforces string, key, and serialized byte limits. + */ +export function executorModelJson(value: unknown): JsonValue { + let nodes = 0; + const ancestors = new Set(); + function copy(input: unknown, depth: number): unknown { + if (++nodes > 100_000 || depth > 128) throw new TypeError("Invalid managed model data"); + if (input === null || typeof input !== "object") return input; + if (ancestors.has(input)) throw new TypeError("Invalid managed model data"); + const array = Array.isArray(input); + if ( + !array && Object.getPrototypeOf(input) !== Object.prototype && + Object.getPrototypeOf(input) !== null + ) throw new TypeError("Invalid managed model data"); + ancestors.add(input); + const output: Record | unknown[] = array ? [] : {}; + const keys = Reflect.ownKeys(input); + if (keys.length > 100_000) throw new TypeError("Invalid managed model data"); + if (array && (keys.length !== input.length + 1 || input.length > 100_000)) { + throw new TypeError("Invalid managed model data"); + } + for (const key of keys) { + if (array && key === "length") continue; + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + typeof key !== "string" || !descriptor || !("value" in descriptor) || !descriptor.enumerable + ) throw new TypeError("Invalid managed model data"); + if (!array && descriptor.value === undefined) continue; + Object.defineProperty(output, key, { + value: copy(descriptor.value, depth + 1), + enumerable: true, + writable: true, + configurable: true, + }); + } + ancestors.delete(input); + return output; + } + const snapshot = snapshotBoundedJsonValue(copy(value, 0)); + if (!snapshot.success) throw new TypeError("Invalid managed model data"); + return snapshot.value; +} diff --git a/src/agent/hosted/executor-node-transport.ts b/src/agent/hosted/executor-node-transport.ts new file mode 100644 index 0000000000..5106f3e1f5 --- /dev/null +++ b/src/agent/hosted/executor-node-transport.ts @@ -0,0 +1,378 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { type AddressInfo, isIP, type Socket } from "node:net"; +import process from "node:process"; +import { connect, createServer, type TLSSocket } from "node:tls"; +import type { ExecutorByteTransport } from "#veryfront/agent/executor/channel.ts"; +import type { ExecutorBinding } from "#veryfront/agent/executor/protocol.ts"; + +const MAX_CHUNK_BYTES = 1024 * 1024; +const HIGH_WATER_MARK = 16 * 1024; +const HANDSHAKE_TIMEOUT_MS = 5_000; +const MAX_PENDING_SOCKETS = 4; +const TLS_OPTIONS = { + minVersion: "TLSv1.3", + maxVersion: "TLSv1.3", + ciphers: "TLS_AES_128_GCM_SHA256", + highWaterMark: HIGH_WATER_MARK, +} as const; + +interface ExecutorTransportOptions { + binding: ExecutorBinding; + /** Fresh 32-byte allocation authority, delivered to both peers by their owner. */ + key: Uint8Array; + signal?: AbortSignal; + /** Total lifetime from setup. Use the allocation's remaining lifetime. Default 30s, maximum 24h. */ + timeoutMs?: number; +} + +export interface ConnectExecutorTransportOptions extends ExecutorTransportOptions { + /** IP literal returned by the trusted allocator. Hostnames and URLs are rejected. */ + podIp: string; + port: number; +} + +export interface ListenExecutorTransportOptions extends ExecutorTransportOptions { + host: string; + /** Zero requests an ephemeral port. */ + port: number; +} + +export interface ExecutorNodeTransport extends ExecutorByteTransport { + /** Permanently destroy both directions and settle pending I/O. */ + close(): void; +} + +export interface ExecutorTransportListener { + readonly address: Readonly; + readonly connection: Promise; + /** Revoke the allocation, including an already attached connection. */ + close(): void; +} + +function validateOptions( + options: ExecutorTransportOptions, + host: string, + port: number, + listen = false, +) { + if ( + "Deno" in globalThis || "Bun" in globalThis || process.release.name !== "node" || + Number(process.versions.node.split(".")[0]) < 22 + ) { + throw new Error("Executor TLS transport requires Node.js 22 or newer"); + } + const binding = options.binding; + if ( + !binding || Object.keys(binding).length !== 3 || + !Object.hasOwn(binding, "allocationId") || !Object.hasOwn(binding, "generation") || + !Object.hasOwn(binding, "invocationId") || + typeof binding.allocationId !== "string" || !binding.allocationId.length || + binding.allocationId.length > 128 || + typeof binding.invocationId !== "string" || !binding.invocationId.length || + binding.invocationId.length > 128 || + !Number.isSafeInteger(binding.generation) || binding.generation <= 0 + ) throw new TypeError("Invalid executor transport binding"); + if (typeof host !== "string" || !isIP(host)) { + throw new TypeError("Executor transport requires an IP literal"); + } + if (!Number.isInteger(port) || port < (listen ? 0 : 1) || port > 65535) { + throw new TypeError("Invalid executor transport port"); + } + if (!(options.key instanceof Uint8Array) || options.key.byteLength !== 32) { + throw new TypeError("Executor transport requires a 32-byte allocation key"); + } + const timeoutMs = options.timeoutMs ?? 30_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 24 * 60 * 60 * 1000) { + throw new TypeError("Invalid executor transport lifetime"); + } + if (options.signal?.aborted) throw new Error("Executor transport aborted"); + // Fixed tuple ordering and a versioned domain prevent ambiguous identity encodings. + const identity = createHash("sha256").update(JSON.stringify([ + "veryfront-executor-tls", + 1, + binding.allocationId, + binding.generation, + binding.invocationId, + ])).digest("hex"); + return { identity, key: Buffer.from(options.key), timeoutMs }; +} + +/** Node-only TLS 1.3 PSK. No certificate fallback, session reuse, or reconnect. */ +export async function connectExecutorTransport( + options: ConnectExecutorTransportOptions, +): Promise { + const { key, identity, timeoutMs } = validateOptions(options, options.podIp, options.port); + return await new Promise((resolve, reject) => { + let socket: TLSSocket | undefined; + let streams: ReturnType | undefined; + let stopped = false; + let offeredPsk = false; + const stop = (error: Error) => { + if (stopped) return; + stopped = true; + clearTimeout(lifetime); + clearTimeout(handshake); + options.signal?.removeEventListener("abort", abort); + key.fill(0); + if (streams) streams.fail(error); + else socket?.destroy(); + reject(error); + }; + const abort = () => stop(new Error("Executor transport aborted")); + const lifetime = setTimeout( + () => stop(new Error("Executor transport deadline exceeded")), + timeoutMs, + ); + const handshake = setTimeout( + () => stop(new Error("Executor transport handshake deadline exceeded")), + Math.min(timeoutMs, HANDSHAKE_TIMEOUT_MS), + ); + options.signal?.addEventListener("abort", abort, { once: true }); + const failed = () => stop(new Error("Executor transport authentication failed")); + try { + socket = connect({ + ...TLS_OPTIONS, + host: options.podIp, + port: options.port, + rejectUnauthorized: true, + pskCallback: () => { + offeredPsk = true; + return { identity, psk: key }; + }, + // Node requires a custom check for certificate-free PSK. A certificate + // must never authorize an endpoint that does not possess the allocation key. + checkServerIdentity: (_host, certificate) => + offeredPsk && Object.keys(certificate).length === 0 + ? undefined + : new Error("Executor transport authentication failed"), + }); + socket.once("error", failed); + socket.once("close", failed); + socket.once("secureConnect", () => { + if (stopped) return; + if ( + !offeredPsk || !socket!.authorized || socket!.getProtocol() !== "TLSv1.3" || + socket!.getCipher().standardName !== TLS_OPTIONS.ciphers || + Object.keys(socket!.getPeerCertificate()).length !== 0 + ) { + failed(); + return; + } + clearTimeout(handshake); + key.fill(0); + socket!.removeListener("error", failed); + socket!.removeListener("close", failed); + streams = socketTransport(socket!, stop); + resolve(streams.transport); + }); + } catch { + failed(); + } + }); +} + +/** + * Listen for exactly one authenticated attachment. The address is ready when + * this resolves; connection resolves only after TLS authentication. Successful + * attachment closes the listener while the established transport stays usable. + */ +export async function listenExecutorTransport( + options: ListenExecutorTransportOptions, +): Promise { + const { key, identity, timeoutMs } = validateOptions(options, options.host, options.port, true); + const attachment = Promise.withResolvers(); + const ready = Promise.withResolvers(); + void attachment.promise.catch(() => {}); + const pending = new Map>(); + const identified = new WeakSet(); + let streams: ReturnType | undefined; + let stopped = false; + let attached = false; + let server: ReturnType; + try { + server = createServer({ + ...TLS_OPTIONS, + handshakeTimeout: HANDSHAKE_TIMEOUT_MS, + pskCallback(socket, presentedIdentity) { + if (stopped || attached || presentedIdentity !== identity) return null; + identified.add(socket); + return key; + }, + }); + } catch { + key.fill(0); + throw new Error("Executor transport listener failed"); + } + server.maxConnections = MAX_PENDING_SOCKETS; + const stop = (error: Error) => { + if (stopped) return; + stopped = true; + clearTimeout(lifetime); + options.signal?.removeEventListener("abort", abort); + key.fill(0); + server.close(); + for (const [socket, timer] of pending) { + clearTimeout(timer); + socket.destroy(); + } + pending.clear(); + streams?.fail(error); + attachment.reject(error); + ready.reject(error); + }; + const abort = () => stop(new Error("Executor transport aborted")); + const lifetime = setTimeout( + () => stop(new Error("Executor transport deadline exceeded")), + timeoutMs, + ); + options.signal?.addEventListener("abort", abort, { once: true }); + server.on("error", () => stop(new Error("Executor transport listener failed"))); + server.on("tlsClientError", (_error, socket) => socket.destroy()); + server.on("connection", (socket) => { + if (stopped || attached || pending.size >= MAX_PENDING_SOCKETS) { + socket.destroy(); + return; + } + const timer = setTimeout(() => socket.destroy(), Math.min(timeoutMs, HANDSHAKE_TIMEOUT_MS)); + pending.set(socket, timer); + socket.once("close", () => { + clearTimeout(timer); + pending.delete(socket); + }); + }); + server.on("secureConnection", (socket) => { + if ( + stopped || attached || !identified.has(socket) || socket.getProtocol() !== "TLSv1.3" || + socket.getCipher().standardName !== TLS_OPTIONS.ciphers + ) { + socket.destroy(); + return; + } + attached = true; + key.fill(0); + // TLS wraps the raw connection. The remote endpoint uniquely identifies the + // accepted TCP connection without accessing Node's private socket fields. + for (const [raw, timer] of pending) { + clearTimeout(timer); + if (raw.remoteAddress !== socket.remoteAddress || raw.remotePort !== socket.remotePort) { + raw.destroy(); + } + } + pending.clear(); + server.close(); + streams = socketTransport(socket, stop); + attachment.resolve(streams.transport); + }); + try { + server.listen({ host: options.host, port: options.port, backlog: MAX_PENDING_SOCKETS }, () => { + if (stopped) { + server.close(); + return; + } + const address = server.address(); + if (!address || typeof address === "string") { + stop(new Error("Executor transport listener failed")); + return; + } + ready.resolve({ + address: Object.freeze(address), + connection: attachment.promise, + close: () => stop(new Error("Executor transport closed")), + }); + }); + } catch { + stop(new Error("Executor transport listener failed")); + } + return await ready.promise; +} + +function socketTransport(socket: TLSSocket, onClose: (error: Error) => void) { + let failure: Error | undefined; + let readController: ReadableStreamDefaultController; + let writeController: WritableStreamDefaultController; + let rejectWrite: ((error: Error) => void) | undefined; + const fail = (error: Error) => { + if (failure) return; + failure = error; + socket.pause(); + socket.removeListener("data", data); + socket.removeListener("end", disconnected); + writeController.signal.removeEventListener("abort", abortWrite); + readController.error(error); + writeController.error(error); + rejectWrite?.(error); + rejectWrite = undefined; + socket.destroy(); + onClose(error); + }; + const disconnected = () => fail(new Error("Executor transport disconnected")); + const aborted = () => fail(new Error("Executor transport aborted")); + // Avoid reentering Node's WritableStream abort state transition from its signal. + const abortWrite = () => queueMicrotask(aborted); + const data = (chunk: Buffer) => { + if (chunk.byteLength > MAX_CHUNK_BYTES) { + fail(new Error("Executor transport chunk exceeds byte limit")); + return; + } + readController.enqueue(chunk); + if ((readController.desiredSize ?? 0) <= 0) socket.pause(); + }; + socket.pause(); + socket.setNoDelay(true); + const readable = new ReadableStream({ + start(controller) { + readController = controller; + }, + pull() { + if (!failure) socket.resume(); + }, + cancel() { + fail(new Error("Executor transport closed")); + }, + }, { highWaterMark: HIGH_WATER_MARK, size: (chunk) => chunk.byteLength }); + const writable = new WritableStream({ + start(controller) { + writeController = controller; + // The signal fires immediately, even while a socket write is blocked. + controller.signal.addEventListener("abort", abortWrite, { once: true }); + }, + write(chunk) { + if (failure) return Promise.reject(failure); + if (!(chunk instanceof Uint8Array) || chunk.byteLength > MAX_CHUNK_BYTES) { + const error = new Error("Executor transport chunk exceeds byte limit"); + fail(error); + return Promise.reject(error); + } + return new Promise((resolve, reject) => { + rejectWrite = reject; + socket.write(new Uint8Array(chunk), (error) => { + rejectWrite = undefined; + if (failure) reject(failure); + else if (error) { + fail(new Error("Executor transport write failed")); + reject(failure); + } else resolve(); + }); + }); + }, + close() { + fail(new Error("Executor transport closed")); + }, + abort: aborted, + }, { + highWaterMark: HIGH_WATER_MARK, + size: (chunk) => chunk instanceof Uint8Array ? chunk.byteLength : 1, + }); + socket.on("data", data); + socket.once("end", disconnected); + socket.on("error", disconnected); + socket.once("close", () => { + disconnected(); + socket.removeListener("error", disconnected); + }); + return { + transport: { readable, writable, close: () => fail(new Error("Executor transport closed")) }, + fail, + }; +} diff --git a/tests/integration/agent/executor-model-channel-node.test.ts b/tests/integration/agent/executor-model-channel-node.test.ts new file mode 100644 index 0000000000..50089bd1f3 --- /dev/null +++ b/tests/integration/agent/executor-model-channel-node.test.ts @@ -0,0 +1,278 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import { setImmediate } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { + connectExecutorTransport, + listenExecutorTransport, +} from "#veryfront/agent/hosted/executor-node-transport.ts"; +import type { + ModelRuntime, + ModelRuntimeCallOptions, + ModelRuntimeGenerateResult, +} from "#veryfront/provider/types.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; + +// Component interoperability over actual local TLS sockets. Both endpoints run +// in one Node process; this does not establish OS or process isolation. +if (typeof Deno !== "undefined") { + it("runs model broker, invocation channel, and TLS interoperability on Node", { + timeout: 25_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 timer = setTimeout(() => child.kill(), 20_000); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assertEquals(code, 0, output); + } finally { + clearTimeout(timer); + child.kill(); + } + }); +} else { + it("carries model results, cancellation, and connection closure through the TLS channel", { + timeout: 10_000, + }, async () => { + const timersBefore = process.getActiveResourcesInfo().filter((kind) => + kind === "Timeout" + ).length; + const modelId = "veryfront-cloud/openai/synthetic-model"; + const allowedModelIds = new Set([modelId]); + const binding = { + allocationId: "synthetic-model-allocation", + generation: 1, + invocationId: "synthetic-model-invocation", + }; + const prompt: ModelRuntimeCallOptions["prompt"] = [{ + role: "user", + content: [{ type: "text", text: "Synthetic prompt" }], + }]; + const generated: ModelRuntimeGenerateResult = { + content: [{ type: "text", text: "Synthetic answer" }], + finishReason: "stop", + usage: { inputTokens: 2, outputTokens: 3 }, + }; + const streamed = [ + { type: "text-delta", delta: "Synthetic introduction" }, + { + type: "tool-error", + toolCallId: "synthetic-search", + toolName: "web_search", + error: [{ type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }], + isError: true, + providerExecuted: true, + }, + { type: "text-delta", delta: "Synthetic continuation" }, + { + type: "finish", + finishReason: "stop", + usage: { inputTokens: 2, outputTokens: 5 }, + }, + ]; + const cancelledStarted = Promise.withResolvers(); + const cancelledAtBroker = Promise.withResolvers(); + const pendingStarted = Promise.withResolvers(); + const pendingAborted = Promise.withResolvers(); + const idleStreamCancelled = Promise.withResolvers(); + const pendingSignals: AbortSignal[] = []; + let generationCalls = 0; + let abortedPendingCalls = 0; + let preparationCalls = 0; + let resolutionCalls = 0; + let generationOptions: ModelRuntimeCallOptions | undefined; + let idleStream = false; + let idleStreamSignal: AbortSignal | undefined; + + const stubModelRuntime: ModelRuntime = { + specificationVersion: "v2", + provider: "veryfront-cloud", + modelId: "synthetic-model", + modelProvider: "openai", + executionMode: "remote", + _generateViaStream: true, + runtimeCapabilities: { toolCalling: true, structuredOutput: ["json_schema"] }, + prepare(signal) { + assert(signal instanceof AbortSignal); + assertEquals(signal.aborted, false); + preparationCalls++; + return Promise.resolve(); + }, + doGenerate(options) { + const call = ++generationCalls; + if (call === 1) { + generationOptions = options; + return Promise.resolve(generated); + } + const signal = options.abortSignal; + assert(signal instanceof AbortSignal); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + if (call === 2) cancelledAtBroker.resolve(); + else if (++abortedPendingCalls === 2) pendingAborted.resolve(); + reject(new Error("Synthetic upstream cancellation")); + }, { once: true }); + if (call === 2) cancelledStarted.resolve(signal); + else { + pendingSignals.push(signal); + if (pendingSignals.length === 2) pendingStarted.resolve(); + } + }); + }, + doStream(options) { + if (idleStream) { + idleStreamSignal = options.abortSignal; + return Promise.resolve({ + stream: new ReadableStream({ + cancel() { + idleStreamCancelled.resolve(); + }, + }, { highWaterMark: 0 }), + }); + } + return Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + for (const part of streamed) controller.enqueue(part); + controller.close(); + }, + }), + }); + }, + }; + const operations = createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime(id) { + resolutionCalls++; + return id === modelId ? stubModelRuntime : undefined; + }, + }); + const key = randomBytes(32); + const listener = await listenExecutorTransport({ + host: "127.0.0.1", + port: 0, + binding, + key, + timeoutMs: 5_000, + }); + const client = await connectExecutorTransport({ + podIp: "127.0.0.1", + port: listener.address.port, + binding, + key, + timeoutMs: 5_000, + }); + key.fill(0); + const server = await listener.connection; + const broker = createExecutorChannel({ binding, transport: server, operations }); + const caller = createExecutorChannel({ binding, transport: client }); + try { + await Promise.all([caller.ready, broker.ready]); + const resolver = await createExecutorModelRuntimeResolver({ + channel: caller, + allowedModelIds, + }); + const proxy = resolver(modelId); + assert(proxy); + for ( + const field of [ + "specificationVersion", + "provider", + "modelId", + "modelProvider", + "executionMode", + "runtimeCapabilities", + "_generateViaStream", + ] as const + ) assertEquals(proxy[field], stubModelRuntime[field]); + await proxy.prepare?.(); + assertEquals(preparationCalls, 1); + assertEquals(await proxy.doGenerate({ prompt, maxOutputTokens: 50 }), generated); + assertEquals(generationOptions?.prompt, prompt); + assertEquals(generationOptions?.maxOutputTokens, 50); + assert(generationOptions?.abortSignal instanceof AbortSignal); + + const { stream } = await proxy.doStream({ prompt }); + const reader = stream.getReader(); + const received: unknown[] = []; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + received.push(next.value); + } + } finally { + reader.releaseLock(); + } + assertEquals(received, streamed); + assertEquals(resolutionCalls, 1); + + const abort = new AbortController(); + const cancelledCall = proxy.doGenerate({ prompt, abortSignal: abort.signal }); + const cancelled = assertRejects(async () => await cancelledCall, Error, "cancelled"); + const brokerSignal = await cancelledStarted.promise; + assert(brokerSignal !== abort.signal); + abort.abort(); + await Promise.all([cancelled, cancelledAtBroker.promise]); + assertEquals(brokerSignal.aborted, true); + + idleStream = true; + const pendingStream = await proxy.doStream({ prompt }); + const pendingReader = pendingStream.stream.getReader(); + const readRejected = assertRejects(() => pendingReader.read(), Error, "Executor"); + const pendingCalls = Promise.allSettled([ + proxy.doGenerate({ prompt }), + proxy.doGenerate({ prompt }), + ]); + await pendingStarted.promise; + // Closing authenticated I/O must propagate through both channel owners. + listener.close(); + const settled = await pendingCalls; + assertEquals(settled.map((result) => result.status), ["rejected", "rejected"]); + await Promise.all([ + readRejected, + pendingAborted.promise, + idleStreamCancelled.promise, + caller.closed, + broker.closed, + ]); + pendingReader.releaseLock(); + assertEquals(pendingSignals.map((signal) => signal.aborted), [true, true]); + assertEquals(idleStreamSignal?.aborted, true); + assertEquals(caller.signal.aborted, true); + assertEquals(broker.signal.aborted, true); + } finally { + caller.close(); + broker.close(); + listener.close(); + client.close(); + key.fill(0); + await Promise.all([caller.closed, broker.closed]); + } + await setImmediate(); + assertEquals( + process.getActiveResourcesInfo().filter((kind) => kind === "Timeout").length, + timersBefore, + "Closing model streams must not create cancellation timers after channel closure", + ); + }); +} diff --git a/tests/integration/agent/executor-node-transport.test.ts b/tests/integration/agent/executor-node-transport.test.ts new file mode 100644 index 0000000000..ef50aa7038 --- /dev/null +++ b/tests/integration/agent/executor-node-transport.test.ts @@ -0,0 +1,401 @@ +import { randomBytes } from "node:crypto"; +import { getEventListeners } from "node:events"; +import { connect as connectTcp, createServer as createTcpServer } from "node:net"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + connectExecutorTransport, + type ExecutorNodeTransport, + listenExecutorTransport, +} from "#veryfront/agent/hosted/executor-node-transport.ts"; + +const binding = { allocationId: "synthetic-allocation", generation: 1, invocationId: "invocation" }; +const host = "127.0.0.1"; +const timeoutMs = 3_000; + +async function pair(signal?: AbortSignal, lifetime = timeoutMs) { + const key = randomBytes(32); + const listener = await listenExecutorTransport({ + host, + port: 0, + binding, + key, + timeoutMs: lifetime, + }); + try { + const client = await connectExecutorTransport({ + podIp: host, + port: listener.address.port, + binding, + key, + signal, + timeoutMs: lifetime, + }); + const server = await listener.connection; + return { listener, client, server }; + } catch (error) { + listener.close(); + throw error; + } finally { + key.fill(0); + } +} + +async function readBytes(transport: ExecutorNodeTransport, length: number) { + const reader = transport.readable.getReader(); + const bytes = new Uint8Array(length); + let offset = 0; + try { + while (offset < length) { + const chunk = await reader.read(); + assert(!chunk.done); + assert(chunk.value.byteLength <= 1024 * 1024); + bytes.set(chunk.value, offset); + offset += chunk.value.byteLength; + } + return bytes; + } finally { + reader.releaseLock(); + } +} + +// Deno's TLS compatibility layer does not implement PSK. Keep this local socket +// integration suite in the normal test:file workflow by running its Node lane. +if (typeof Deno !== "undefined") { + it( + "runs executor TLS authentication and lifecycle coverage on Node", + { timeout: 30_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 timer = setTimeout(() => child.kill(), 25_000); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assertEquals(code, 0, output); + } finally { + clearTimeout(timer); + child.kill(); + } + }, + ); +} else { + describe("Node executor TLS transport", () => { + it("exchanges bounded binary chunks in both directions", { timeout: 5_000 }, async () => { + const { listener, client, server } = await pair(); + const bytes = randomBytes(1024 * 1024); + try { + const receiving = readBytes(server, bytes.byteLength); + const writer = client.writable.getWriter(); + await writer.write(bytes); + assertEquals(await receiving, bytes); + const reply = new Uint8Array([0, 1, 255]); + const response = readBytes(client, reply.byteLength); + await server.writable.getWriter().write(reply); + assertEquals(await response, reply); + } finally { + listener.close(); + client.close(); + } + }); + + for (const mismatch of ["key", "allocationId", "generation", "invocationId"] as const) { + it(`rejects an incorrect ${mismatch} before exposing a connection`, async () => { + const key = randomBytes(32); + const listener = await listenExecutorTransport({ host, port: 0, binding, key, timeoutMs }); + let attached = false; + void listener.connection.then(() => attached = true, () => {}); + try { + const changed = { ...binding }; + if (mismatch === "generation") changed.generation++; + else if (mismatch !== "key") changed[mismatch] += "-other"; + await assertRejects( + () => + connectExecutorTransport({ + podIp: host, + port: listener.address.port, + binding: changed, + key: mismatch === "key" ? randomBytes(32) : key, + timeoutMs, + }), + Error, + "Executor transport authentication failed", + ); + assertEquals(attached, false); + const valid = await connectExecutorTransport({ + podIp: host, + port: listener.address.port, + binding, + key, + timeoutMs, + }); + await listener.connection; + valid.close(); + } finally { + listener.close(); + key.fill(0); + } + }); + } + + it("permits one authenticated attachment and no reconnect", async () => { + const key = randomBytes(32); + const listener = await listenExecutorTransport({ host, port: 0, binding, key, timeoutMs }); + const options = { podIp: host, port: listener.address.port, binding, key, timeoutMs }; + const client = await connectExecutorTransport(options); + try { + const server = await listener.connection; + await assertRejects(() => connectExecutorTransport(options), Error); + const receiving = readBytes(server, 1); + await client.writable.getWriter().write(new Uint8Array([7])); + assertEquals(await receiving, new Uint8Array([7])); + client.close(); + await assertRejects(() => connectExecutorTransport(options), Error); + } finally { + client.close(); + listener.close(); + key.fill(0); + } + }); + + it("rejects DNS names, URLs, invalid keys, bindings, and deadlines", async () => { + const valid = { podIp: host, port: 1, binding, key: randomBytes(32), timeoutMs }; + for ( + const invalid of [ + { podIp: "localhost" }, + { podIp: "https://127.0.0.1" }, + { port: 0 }, + { port: 65536 }, + { key: randomBytes(31) }, + { timeoutMs: 0 }, + { timeoutMs: 24 * 60 * 60 * 1000 + 1 }, + { binding: { ...binding, generation: 0 } }, + { binding: { ...binding, generation: 1.5 } }, + { binding: { ...binding, allocationId: "" } }, + { binding: { ...binding, extra: "unexpected" } }, + ] + ) { + await assertRejects(() => connectExecutorTransport({ ...valid, ...invalid }), TypeError); + } + }); + + it("settles pending reads and queued writes when aborted after attachment", async () => { + const controller = new AbortController(); + const { listener, client, server } = await pair(controller.signal); + try { + const read = assertRejects(() => client.readable.getReader().read(), Error); + const writer = client.writable.getWriter(); + const chunk = new Uint8Array(1024 * 1024); + const writes = Array.from({ length: 64 }, () => writer.write(chunk)); + const results = Promise.allSettled(writes); + await writes[0]; + controller.abort(new Error("synthetic-private-reason")); + await read; + assertEquals(getEventListeners(controller.signal, "abort").length, 0); + assert((await results).some((result) => result.status === "rejected")); + const peerReader = server.readable.getReader(); + await assertRejects( + async () => { + while (!(await peerReader.read()).done) { + /* Drain bytes written before cancellation. */ + } + }, + Error, + "Executor transport", + ); + } finally { + listener.close(); + client.close(); + } + }); + + it("writable abort interrupts an active blocked write", async () => { + const { listener, client } = await pair(); + try { + const writer = client.writable.getWriter(); + const chunk = new Uint8Array(1024 * 1024); + const queued = Array.from({ length: 64 }, () => writer.write(chunk)); + const writes = Promise.allSettled(queued); + await queued[0]; + await writer.abort(new Error("synthetic-private-reason")); + assert((await writes).some((result) => result.status === "rejected")); + await assertRejects(() => client.readable.getReader().read(), Error); + } finally { + listener.close(); + client.close(); + } + }); + + it("rejects oversized writes and closes the connection", async () => { + const { listener, client } = await pair(); + try { + await assertRejects( + () => client.writable.getWriter().write(new Uint8Array(1024 * 1024 + 1)), + Error, + "Executor transport chunk exceeds byte limit", + ); + await assertRejects(() => client.readable.getReader().read(), Error); + } finally { + listener.close(); + client.close(); + } + }); + + it("bounds attachment wait and the established connection lifetime", async () => { + const listener = await listenExecutorTransport({ + host, + port: 0, + binding, + key: randomBytes(32), + timeoutMs: 50, + }); + await assertRejects(() => listener.connection, Error, "Executor transport deadline exceeded"); + listener.close(); + const connected = await pair(undefined, 100); + try { + await assertRejects( + () => connected.client.readable.getReader().read(), + Error, + "Executor transport", + ); + } finally { + connected.listener.close(); + connected.client.close(); + } + }); + + it("revokes an attached transport when the listener is aborted", async () => { + const signalController = new AbortController(); + const key = randomBytes(32); + const listener = await listenExecutorTransport({ + host, + port: 0, + binding, + key, + signal: signalController.signal, + timeoutMs, + }); + const client = await connectExecutorTransport({ + podIp: host, + port: listener.address.port, + binding, + key, + timeoutMs, + }); + const server = await listener.connection; + try { + const serverRead = assertRejects(() => server.readable.getReader().read(), Error); + const clientRead = assertRejects(() => client.readable.getReader().read(), Error); + signalController.abort(); + await Promise.all([serverRead, clientRead]); + assertEquals(getEventListeners(signalController.signal, "abort").length, 0); + } finally { + listener.close(); + client.close(); + key.fill(0); + } + }); + + it("read cancellation destroys the peer connection", async () => { + const { listener, client, server } = await pair(); + try { + const peerRead = assertRejects(() => server.readable.getReader().read(), Error); + await client.readable.cancel(); + await peerRead; + await assertRejects(() => client.writable.getWriter().write(new Uint8Array([1])), Error); + } finally { + listener.close(); + client.close(); + } + }); + + it("aborts a pending client handshake and closes the TCP socket", async () => { + const tcp = createTcpServer(); + const accepted = new Promise((resolve) => + tcp.once("connection", resolve) + ); + await new Promise((resolve) => tcp.listen(0, host, resolve)); + const address = tcp.address(); + assert(address && typeof address !== "string"); + const controller = new AbortController(); + const connecting = connectExecutorTransport({ + podIp: host, + port: address.port, + binding, + key: randomBytes(32), + signal: controller.signal, + timeoutMs, + }); + const rejected = assertRejects(() => connecting, Error, "Executor transport aborted"); + const raw = await accepted; + raw.resume(); + const closed = new Promise((resolve) => raw.once("close", resolve)); + controller.abort(); + try { + await rejected; + await closed; + } finally { + raw.destroy(); + await new Promise((resolve) => tcp.close(() => resolve())); + } + }); + + it("bounds unauthenticated sockets and destroys them on listener close", async () => { + const listener = await listenExecutorTransport({ + host, + port: 0, + binding, + key: randomBytes(32), + timeoutMs, + }); + const sockets = Array.from( + { length: 5 }, + () => connectTcp({ host, port: listener.address.port }), + ); + const closed = sockets.map((socket) => + new Promise((resolve) => { + socket.on("error", () => {}); + socket.once("close", resolve); + }) + ); + try { + await closed[4]; + listener.close(); + await Promise.all(closed); + await assertRejects(() => listener.connection, Error, "Executor transport closed"); + } finally { + listener.close(); + for (const socket of sockets) socket.destroy(); + } + }); + + it("rejects already-aborted setup without opening a listener", async () => { + const signal = AbortSignal.abort(new Error("synthetic-private-reason")); + await assertRejects( + () => + listenExecutorTransport({ + host, + port: 0, + binding, + key: randomBytes(32), + signal, + timeoutMs, + }), + Error, + "Executor transport aborted", + ); + }); + }); +} From 5eaed3d0ed3f02c8c5146f32941639c1ae90a499 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 16:02:28 +0200 Subject: [PATCH 003/194] fix(agent): retain executor provider stream cleanup --- .../hosted/executor-model-bridge.test.ts | 100 +++++++++++++++++- src/agent/hosted/executor-model-bridge.ts | 10 +- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/agent/hosted/executor-model-bridge.test.ts b/src/agent/hosted/executor-model-bridge.test.ts index c4757dd68f..97e587cf9c 100644 --- a/src/agent/hosted/executor-model-bridge.test.ts +++ b/src/agent/hosted/executor-model-bridge.test.ts @@ -55,7 +55,10 @@ function stubModel( }; } -function pair(operations: ReadonlyMap) { +function pair( + operations: ReadonlyMap, + options: { maxConcurrentCalls?: number; brokerCancellationTimeoutMs?: number } = {}, +) { const forward = new TransformStream(); const backward = new TransformStream(); const binding = { @@ -65,10 +68,13 @@ function pair(operations: ReadonlyMap) { }; const caller = createExecutorChannel({ binding, + maxConcurrentCalls: options.maxConcurrentCalls, transport: { readable: backward.readable, writable: forward.writable }, }); const broker = createExecutorChannel({ binding, + maxConcurrentCalls: options.maxConcurrentCalls, + cancellationTimeoutMs: options.brokerCancellationTimeoutMs, transport: { readable: forward.readable, writable: backward.writable }, operations, }); @@ -82,12 +88,13 @@ function pair(operations: ReadonlyMap) { }; } -async function connected(model = stubModel()) { +async function connected(model = stubModel(), options: Parameters[1] = {}) { const channels = pair( createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: (id) => id === modelId ? model : undefined, }), + options, ); const resolver = await createExecutorModelRuntimeResolver({ channel: channels.caller, @@ -710,6 +717,95 @@ describe("executor managed model bridge", () => { } }); + it("retains admission until asynchronous provider stream cleanup settles", async () => { + const cleanup = Promise.withResolvers(); + const cancelStarted = Promise.withResolvers(); + let cancelCalls = 0; + let cancellationSettled = false; + let cancellation: Promise | undefined; + const channels = await connected( + stubModel({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + cancel() { + cancelCalls++; + cancelStarted.resolve(); + return cleanup.promise; + }, + }, { highWaterMark: 0 }), + }), + }), + { maxConcurrentCalls: 1 }, + ); + try { + const { stream } = await channels.proxy.doStream({ prompt }); + cancellation = stream.cancel(); + void cancellation.then( + () => cancellationSettled = true, + () => cancellationSettled = true, + ); + await cancelStarted.promise; + await tick(); + assertEquals(cancellationSettled, false); + await assertRejects( + async () => await channels.proxy.doGenerate({ prompt }), + Error, + "concurrent call limit", + ); + cleanup.resolve(); + await cancellation; + assertEquals(cancelCalls, 1); + assertEquals( + await channels.proxy.doGenerate({ prompt }), + await stubModel().doGenerate({ prompt }), + ); + } finally { + cleanup.resolve(); + await cancellation?.catch(() => {}); + await channels.close(); + } + }); + + it("enforces the cancellation deadline while provider stream cleanup remains pending", async () => { + const cleanup = Promise.withResolvers(); + const cancelStarted = Promise.withResolvers(); + let cancellation: Promise | undefined; + const channels = await connected( + stubModel({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + cancel() { + cancelStarted.resolve(); + return cleanup.promise; + }, + }, { highWaterMark: 0 }), + }), + }), + { maxConcurrentCalls: 1, brokerCancellationTimeoutMs: 20 }, + ); + try { + const { stream } = await channels.proxy.doStream({ prompt }); + cancellation = stream.cancel(); + void cancellation.catch(() => {}); + await cancelStarted.promise; + await new Promise((resolve) => setTimeout(resolve, 50)); + assertEquals(channels.broker.signal.aborted, true); + assertEquals( + (await channels.broker.closed).message, + "Executor handler cancellation deadline exceeded", + ); + await assertRejects(async () => await cancellation, Error, "Executor"); + assertEquals(channels.caller.signal.aborted, true); + } finally { + // The provider does not finish during the deadline; release the fixture only during teardown. + cleanup.resolve(); + await cancellation?.catch(() => {}); + await channels.close(); + } + }); + it("finishes a stream and keeps transport response fields out of generation results", async () => { const channels = await connected(stubModel({ doGenerate: () => diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 1fd149ebae..f60d93ca81 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -99,8 +99,14 @@ export function createExecutorModelBroker(options: { const call = parseCall(input, context); const result = await call.model.doStream(call.options); const reader = result.stream.getReader(); + // Later reader.cancel() calls do not wait for the first call's provider cleanup. + let cancellation: Promise | undefined; const cancel = () => { - void reader.cancel().catch(() => {}); + if (!cancellation) { + cancellation = reader.cancel(); + void cancellation.catch(() => {}); + } + return cancellation; }; context.signal.addEventListener("abort", cancel, { once: true }); let complete = false; @@ -129,7 +135,7 @@ export function createExecutorModelBroker(options: { } } finally { context.signal.removeEventListener("abort", cancel); - if (!complete) await reader.cancel().catch(() => {}); + if (!complete) await cancel().catch(() => {}); reader.releaseLock(); } }, From 22f682e58d7e7ae71796d3bcc9519065ae3b1fec Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 16:28:58 +0200 Subject: [PATCH 004/194] test(agent): classify provider bridge contracts as integration tests --- .../hosted/executor-model-bridge.test.ts | 147 ------------- .../executor-model-provider-contract.test.ts | 193 ++++++++++++++++++ 2 files changed, 193 insertions(+), 147 deletions(-) create mode 100644 tests/integration/agent/executor-model-provider-contract.test.ts diff --git a/src/agent/hosted/executor-model-bridge.test.ts b/src/agent/hosted/executor-model-bridge.test.ts index 97e587cf9c..48e382db35 100644 --- a/src/agent/hosted/executor-model-bridge.test.ts +++ b/src/agent/hosted/executor-model-bridge.test.ts @@ -1,7 +1,4 @@ import "#veryfront/schemas/_test-setup.ts"; -import { createAnthropicProviderModel } from "@veryfront/ext-llm-anthropic"; -import { createGoogleProviderModel } from "@veryfront/ext-llm-google"; -import { observeFetchRequestInit, withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; @@ -433,150 +430,6 @@ describe("executor managed model bridge", () => { } }); - it("keeps the allowed model pinned through the first-party Anthropic request builder", async () => { - const allowedId = "veryfront-cloud/anthropic/claude-haiku-4-5"; - const allowedIds = new Set([allowedId]); - const requestedModels: unknown[] = []; - const mockFetch: typeof fetch = (_input, init) => { - const { body } = observeFetchRequestInit(init); - assert(typeof body === "string"); - requestedModels.push(JSON.parse(body).model); - return Promise.resolve( - new Response( - JSON.stringify({ - content: [{ type: "text", text: "Synthetic answer" }], - stop_reason: "end_turn", - usage: { input_tokens: 1, output_tokens: 1 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - }; - await withMockFetch(mockFetch, async () => { - const model = createAnthropicProviderModel("claude-haiku-4-5", { - credential: "", - name: "veryfront-cloud", - baseURL: "https://example.com/v1", - fetch: mockFetch, - }); - const channels = pair( - createExecutorModelBroker({ - allowedModelIds: allowedIds, - resolveModelRuntime: () => model, - }), - ); - try { - const resolver = await createExecutorModelRuntimeResolver({ - channel: channels.caller, - allowedModelIds: allowedIds, - }); - const proxy = resolver(allowedId)!; - const result = await proxy.doGenerate({ - prompt, - maxOutputTokens: 10, - providerOptions: { anthropic: { temperature: 0.2 } }, - }); - assertEquals(result.content, [{ type: "text", text: "Synthetic answer" }]); - assertEquals(requestedModels, ["claude-haiku-4-5"]); - for (const bucket of ["anthropic", "veryfront-cloud"]) { - await assertRejects( - () => - channels.caller.request("model.generate", { - modelId: allowedId, - options: { prompt, providerOptions: { [bucket]: { model: "claude-opus-4-6" } } }, - } as unknown as JsonValue), - Error, - "operation-failed", - ); - await assertRejects( - async () => - await proxy.doGenerate({ - prompt, - providerOptions: { [bucket]: { model: "claude-opus-4-6" } }, - }), - TypeError, - ); - } - assertEquals(requestedModels, ["claude-haiku-4-5"]); - } finally { - await channels.close(); - } - }); - }); - - it("preserves schema property names through the first-party Google request builder", async () => { - const allowedId = "veryfront-cloud/google/gemini-synthetic"; - const allowedIds = new Set([allowedId]); - const responseSchema = { - type: "OBJECT", - properties: { - url: { type: "STRING" }, - auth: { type: "STRING" }, - model: { type: "STRING" }, - headers: { type: "STRING" }, - }, - }; - const schemas: unknown[] = []; - const mockFetch: typeof fetch = (_input, init) => { - const { body } = observeFetchRequestInit(init); - assert(typeof body === "string"); - schemas.push(JSON.parse(body).generationConfig.responseSchema); - return Promise.resolve( - new Response( - JSON.stringify({ - candidates: [{ - content: { parts: [{ text: "Synthetic answer" }] }, - finishReason: "STOP", - }], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - }; - await withMockFetch(mockFetch, async () => { - const model = createGoogleProviderModel("gemini-synthetic", { - credential: "", - name: "veryfront-cloud", - baseURL: "https://example.com/v1", - fetch: mockFetch, - }); - const channels = pair( - createExecutorModelBroker({ - allowedModelIds: allowedIds, - resolveModelRuntime: () => model, - }), - ); - try { - const resolver = await createExecutorModelRuntimeResolver({ - channel: channels.caller, - allowedModelIds: allowedIds, - }); - const proxy = resolver(allowedId)!; - await proxy.doGenerate({ - prompt, - providerOptions: { - google: { generationConfig: { responseMimeType: "application/json", responseSchema } }, - }, - }); - assertEquals(schemas, [responseSchema]); - for (const field of ["url", "auth", "model", "headers"]) { - await assertRejects( - () => - channels.caller.request("model.generate", { - modelId: allowedId, - options: { prompt, providerOptions: { google: { [field]: "synthetic-override" } } }, - } as unknown as JsonValue), - Error, - "operation-failed", - ); - } - assertEquals(schemas, [responseSchema]); - } finally { - await channels.close(); - } - }); - }); - it("forbids proxy header overrides and accepts omitted optional values", async () => { const channels = await connected(); try { diff --git a/tests/integration/agent/executor-model-provider-contract.test.ts b/tests/integration/agent/executor-model-provider-contract.test.ts new file mode 100644 index 0000000000..3fef3329e2 --- /dev/null +++ b/tests/integration/agent/executor-model-provider-contract.test.ts @@ -0,0 +1,193 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createAnthropicProviderModel } from "@veryfront/ext-llm-anthropic"; +import { createGoogleProviderModel } from "@veryfront/ext-llm-google"; +import { observeFetchRequestInit, withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { + createExecutorChannel, + type ExecutorOperation, +} from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; + +const prompt: ModelRuntimeCallOptions["prompt"] = [{ + role: "user", + content: [{ type: "text", text: "Synthetic prompt" }], +}]; + +function pair(operations: ReadonlyMap) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + }); + return { + caller, + async close() { + caller.close(); + await broker.closed; + }, + }; +} + +describe("executor model provider contracts", () => { + it("keeps the allowed model pinned through the first-party Anthropic request builder", async () => { + const allowedId = "veryfront-cloud/anthropic/claude-haiku-4-5"; + const allowedIds = new Set([allowedId]); + const requestedModels: unknown[] = []; + const mockFetch: typeof fetch = (_input, init) => { + const { body } = observeFetchRequestInit(init); + assert(typeof body === "string"); + requestedModels.push(JSON.parse(body).model); + return Promise.resolve( + new Response( + JSON.stringify({ + content: [{ type: "text", text: "Synthetic answer" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }; + await withMockFetch(mockFetch, async () => { + const model = createAnthropicProviderModel("claude-haiku-4-5", { + credential: "", + name: "veryfront-cloud", + baseURL: "https://example.com/v1", + fetch: mockFetch, + }); + const channels = pair( + createExecutorModelBroker({ + allowedModelIds: allowedIds, + resolveModelRuntime: () => model, + }), + ); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds: allowedIds, + }); + const proxy = resolver(allowedId)!; + const result = await proxy.doGenerate({ + prompt, + maxOutputTokens: 10, + providerOptions: { anthropic: { temperature: 0.2 } }, + }); + assertEquals(result.content, [{ type: "text", text: "Synthetic answer" }]); + assertEquals(requestedModels, ["claude-haiku-4-5"]); + for (const bucket of ["anthropic", "veryfront-cloud"]) { + await assertRejects( + () => + channels.caller.request("model.generate", { + modelId: allowedId, + options: { prompt, providerOptions: { [bucket]: { model: "claude-opus-4-6" } } }, + } as unknown as JsonValue), + Error, + "operation-failed", + ); + await assertRejects( + async () => + await proxy.doGenerate({ + prompt, + providerOptions: { [bucket]: { model: "claude-opus-4-6" } }, + }), + TypeError, + ); + } + assertEquals(requestedModels, ["claude-haiku-4-5"]); + } finally { + await channels.close(); + } + }); + }); + + it("preserves schema property names through the first-party Google request builder", async () => { + const allowedId = "veryfront-cloud/google/gemini-synthetic"; + const allowedIds = new Set([allowedId]); + const responseSchema = { + type: "OBJECT", + properties: { + url: { type: "STRING" }, + auth: { type: "STRING" }, + model: { type: "STRING" }, + headers: { type: "STRING" }, + }, + }; + const schemas: unknown[] = []; + const mockFetch: typeof fetch = (_input, init) => { + const { body } = observeFetchRequestInit(init); + assert(typeof body === "string"); + schemas.push(JSON.parse(body).generationConfig.responseSchema); + return Promise.resolve( + new Response( + JSON.stringify({ + candidates: [{ + content: { parts: [{ text: "Synthetic answer" }] }, + finishReason: "STOP", + }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }; + await withMockFetch(mockFetch, async () => { + const model = createGoogleProviderModel("gemini-synthetic", { + credential: "", + name: "veryfront-cloud", + baseURL: "https://example.com/v1", + fetch: mockFetch, + }); + const channels = pair( + createExecutorModelBroker({ + allowedModelIds: allowedIds, + resolveModelRuntime: () => model, + }), + ); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds: allowedIds, + }); + const proxy = resolver(allowedId)!; + await proxy.doGenerate({ + prompt, + providerOptions: { + google: { generationConfig: { responseMimeType: "application/json", responseSchema } }, + }, + }); + assertEquals(schemas, [responseSchema]); + for (const field of ["url", "auth", "model", "headers"]) { + await assertRejects( + () => + channels.caller.request("model.generate", { + modelId: allowedId, + options: { prompt, providerOptions: { google: { [field]: "synthetic-override" } } }, + } as unknown as JsonValue), + Error, + "operation-failed", + ); + } + assertEquals(schemas, [responseSchema]); + } finally { + await channels.close(); + } + }); + }); +}); From e4f7cd262b278f8e339ba77070ac48350b587f6a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 16:48:31 +0200 Subject: [PATCH 005/194] fix(agent): preserve provider metadata across executor continuations --- .../hosted/executor-model-bridge.test.ts | 83 +++++++++++++++++ src/agent/hosted/executor-model-bridge.ts | 51 ++++++++++- src/agent/hosted/executor-model-schema.ts | 28 +++++- src/agent/runtime/index.ts | 16 ++-- .../executor-model-reconciliation.test.ts | 88 +++++++++++++++++++ 5 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 tests/integration/agent/executor-model-reconciliation.test.ts diff --git a/src/agent/hosted/executor-model-bridge.test.ts b/src/agent/hosted/executor-model-bridge.test.ts index 48e382db35..a14d592453 100644 --- a/src/agent/hosted/executor-model-bridge.test.ts +++ b/src/agent/hosted/executor-model-bridge.test.ts @@ -104,6 +104,89 @@ async function connected(model = stubModel(), options: Parameters[1 const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("executor managed model bridge", () => { + it("reconciles bounded metadata through the broker and omits unsupported hooks", async () => { + const plain = await connected(); + try { + assertEquals(plain.proxy._reconcileProviderMetadata, undefined); + } finally { + await plain.close(); + } + let calls = 0; + const channels = await connected(stubModel({ + _reconcileProviderMetadata(input: { providerMetadata: Record }) { + calls++; + return calls === 1 ? input.providerMetadata : undefined; + }, + })); + try { + const reconcile = channels.proxy._reconcileProviderMetadata; + assert(typeof reconcile === "function"); + const input = { providerMetadata: { synthetic: [1, 2] }, suppressedToolCalls: [] }; + assertEquals(await reconcile(input), input.providerMetadata); + assertEquals(await reconcile(input), undefined); + await assertRejects(() => + channels.caller.request("model.reconcile", { + modelId, + ...input, + suppressedToolCalls: [{ id: "tool", name: "lookup", unexpected: true }], + }) + ); + assertEquals(calls, 2); + revokeModelRuntimeResolver(channels.resolver); + await assertRejects(() => reconcile(input)); + assertEquals(calls, 2); + } finally { + await channels.close(); + } + }); + + it("cancels remote metadata reconciliation and rejects executable serialization hooks", async () => { + const entered = Promise.withResolvers(); + const channels = await connected(stubModel({ + _reconcileProviderMetadata(input: { abortSignal: AbortSignal }) { + entered.resolve(input.abortSignal); + return new Promise((_, reject) => { + input.abortSignal.addEventListener("abort", () => reject(new Error("Cancelled")), { + once: true, + }); + }); + }, + })); + try { + const reconcile = channels.proxy._reconcileProviderMetadata; + assert(typeof reconcile === "function"); + let serializationCalls = 0; + const invalid = [1]; + Object.defineProperty(invalid, "toJSON", { + value: () => { + serializationCalls++; + return []; + }, + }); + await assertRejects(() => + reconcile({ + providerMetadata: { invalid }, + suppressedToolCalls: [], + }) + ); + assertEquals(serializationCalls, 0); + const controller = new AbortController(); + const pending = reconcile({ + providerMetadata: {}, + suppressedToolCalls: [], + abortSignal: controller.signal, + }); + const rejected = assertRejects(() => pending); + const upstream = await entered.promise; + controller.abort(); + await rejected; + await tick(); + assertEquals(upstream.aborted, true); + } finally { + await channels.close(); + } + }); + it("preserves metadata, readiness, neutral generation options, and data results", async () => { let received: ModelRuntimeCallOptions | undefined; let prepared = 0; diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index f60d93ca81..0acde235db 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -18,6 +18,8 @@ import { getExecutorModelGenerateResultSchema, getExecutorModelMetadataSchema, getExecutorModelOptionsSchema, + getExecutorModelReconciliationResultSchema, + getExecutorModelReconciliationSchema, getExecutorModelRequestSchema, getExecutorModelStreamFrameSchema, parseExecutorModelData, @@ -74,6 +76,28 @@ export function createExecutorModelBroker(options: { return null; }, }], + ["model.reconcile", { + mode: "unary", + async handle(input, context) { + const request = parseExecutorModelData(getExecutorModelReconciliationSchema(), input); + context.signal.throwIfAborted(); + const model = getModel(request.modelId); + const reconcile = model._reconcileProviderMetadata; + if (typeof reconcile !== "function") { + throw new TypeError("Managed model metadata reconciliation is unavailable"); + } + const providerMetadata = await reconcile.call(model, { + providerMetadata: request.providerMetadata, + suppressedToolCalls: request.suppressedToolCalls, + abortSignal: context.signal, + }); + context.signal.throwIfAborted(); + return executorModelJson(parseExecutorModelData( + getExecutorModelReconciliationResultSchema(), + executorModelJson({ providerMetadata }), + )); + }, + }], ["model.generate", { mode: "unary", async handle(input, context) { @@ -153,6 +177,9 @@ function modelMetadata(id: string, model: ModelRuntime) { executionMode: model.executionMode, runtimeCapabilities: model.runtimeCapabilities, _generateViaStream: model._generateViaStream, + ...(typeof model._reconcileProviderMetadata === "function" + ? { reconcilesProviderMetadata: true } + : {}), }; } @@ -225,7 +252,7 @@ function createExecutorModelRuntime( authoritySignal: AbortSignal, assertActive: () => void, ): ModelRuntime { - const { id, ...metadata } = descriptor; + const { id, reconcilesProviderMetadata, ...metadata } = descriptor; const withAuthority = (signal?: AbortSignal) => signal ? AbortSignal.any([authoritySignal, signal]) : authoritySignal; const makeCall = (options: ModelRuntimeCallOptions) => { @@ -240,6 +267,28 @@ function createExecutorModelRuntime( }; return Object.freeze({ ...metadata, + ...(reconcilesProviderMetadata + ? { + async _reconcileProviderMetadata(input: { + providerMetadata: Record; + suppressedToolCalls: readonly { id: string; name: string }[]; + abortSignal?: AbortSignal; + }) { + assertActive(); + const request = executorModelJson({ + modelId: id, + providerMetadata: input.providerMetadata, + suppressedToolCalls: input.suppressedToolCalls, + }); + parseExecutorModelData(getExecutorModelReconciliationSchema(), request); + const result = await channel.request("model.reconcile", request, { + signal: withAuthority(input.abortSignal), + }); + return parseExecutorModelData(getExecutorModelReconciliationResultSchema(), result) + .providerMetadata; + }, + } + : {}), async prepare(signal?: AbortSignal) { assertActive(); const combinedSignal = withAuthority(signal); diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts index 9273d660c7..a1f09d4986 100644 --- a/src/agent/hosted/executor-model-schema.ts +++ b/src/agent/hosted/executor-model-schema.ts @@ -181,6 +181,23 @@ export const getExecutorModelRequestSchema = defineSchema((v) => v.object({ modelId: getModelIdSchema() }).strict() ); +export const getExecutorModelReconciliationSchema = defineSchema((v) => + v.object({ + modelId: getModelIdSchema(), + providerMetadata: v.record(v.string(), getJsonValueSchema()), + suppressedToolCalls: v.array( + v.object({ + id: v.string().min(1).max(256), + name: v.string().min(1).max(256), + }).strict(), + ).max(MAX_ITEMS), + }).strict() +); + +export const getExecutorModelReconciliationResultSchema = defineSchema((v) => + v.object({ providerMetadata: v.record(v.string(), getJsonValueSchema()).optional() }).strict() +); + export const getExecutorModelCallSchema = defineSchema((v) => v.object({ modelId: getModelIdSchema(), options: getExecutorModelOptionsSchema() }).strict() ); @@ -202,6 +219,7 @@ export const getExecutorModelMetadataSchema = defineSchema((v) => ]).optional(), }).strict().optional(), _generateViaStream: v.boolean().optional(), + reconcilesProviderMetadata: v.boolean().optional(), }).strict(), ).max(MAX_MODELS) ); @@ -268,11 +286,19 @@ export function executorModelJson(value: unknown): JsonValue { const output: Record | unknown[] = array ? [] : {}; const keys = Reflect.ownKeys(input); if (keys.length > 100_000) throw new TypeError("Invalid managed model data"); - if (array && (keys.length !== input.length + 1 || input.length > 100_000)) { + // First-party provider snapshots pin an inert own toJSON value on arrays. + // Copy only their indexed data; never invoke or transport a serialization hook. + const guard = array ? Object.getOwnPropertyDescriptor(input, "toJSON") : undefined; + const guardedArray = guard !== undefined && "value" in guard && guard.value === undefined && + guard.enumerable === false && guard.configurable === false && guard.writable === false; + if ( + array && (keys.length !== input.length + 1 + (guardedArray ? 1 : 0) || input.length > 100_000) + ) { throw new TypeError("Invalid managed model data"); } for (const key of keys) { if (array && key === "length") continue; + if (guardedArray && key === "toJSON") continue; const descriptor = Object.getOwnPropertyDescriptor(input, key); if ( typeof key !== "string" || !descriptor || !("value" in descriptor) || !descriptor.enumerable diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 626a5aeb3b..2a7d397684 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -3760,11 +3760,12 @@ export class AgentRuntime { }); attachProviderMetadata( assistantMessage, - reconcileSuppressedProviderMetadata( + await reconcileSuppressedProviderMetadata( languageModel, state.providerMetadata, state.suppressedToolCalls, state.toolCalls.size > 0, + abortSignal, ), ); @@ -4460,7 +4461,8 @@ export class AgentRuntime { type ProviderMetadataReconciler = (input: { providerMetadata: Record; suppressedToolCalls: readonly { id: string; name: string }[]; -}) => Record | undefined; + abortSignal?: AbortSignal; +}) => Record | undefined | Promise | undefined>; /** * Best-effort structured-output parse for the max-steps exit. @@ -4508,12 +4510,14 @@ function getFinalAssistantText(messages: Message[]): string { return ""; } -function reconcileSuppressedProviderMetadata( +async function reconcileSuppressedProviderMetadata( modelRuntime: ModelRuntime, providerMetadata: Record | undefined, suppressedToolCalls: readonly { id: string; name: string }[], hasSurvivingToolCalls: boolean, -): Record | undefined { + abortSignal?: AbortSignal, +): Promise | undefined> { + throwIfAborted(abortSignal); if (providerMetadata === undefined || suppressedToolCalls.length === 0) { return providerMetadata; } @@ -4523,10 +4527,12 @@ function reconcileSuppressedProviderMetadata( return undefined; } - const reconciled = (reconcile as ProviderMetadataReconciler).call(modelRuntime, { + const reconciled = await (reconcile as ProviderMetadataReconciler).call(modelRuntime, { providerMetadata, suppressedToolCalls, + abortSignal, }); + throwIfAborted(abortSignal); if (reconciled === undefined) { if (!hasSurvivingToolCalls) { return undefined; diff --git a/tests/integration/agent/executor-model-reconciliation.test.ts b/tests/integration/agent/executor-model-reconciliation.test.ts new file mode 100644 index 0000000000..068e1eec20 --- /dev/null +++ b/tests/integration/agent/executor-model-reconciliation.test.ts @@ -0,0 +1,88 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { agent } from "#veryfront/agent/index.ts"; +import { tool } from "#veryfront/tool"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { reconcileGoogleProviderMetadata } from "../../../extensions/ext-llm-google/src/google-thought-signatures.ts"; + +it("preserves surviving Gemini signatures through remote reconciliation and the next model call", async () => { + const suppressed = { + functionCall: { id: "stale-1", name: "missing_tool", args: {} }, + thoughtSignature: "synthetic-suppressed-signature", + }; + const surviving = { + functionCall: { id: "lookup-1", name: "lookup", args: { query: "Synthetic" } }, + thoughtSignature: "synthetic-surviving-signature", + }; + const providerMetadata = { google: { rawAssistantParts: [suppressed, surviving] } }; + const model = scriptedModel([ + { + toolCalls: [ + { id: "stale-1", name: "missing_tool", input: {} }, + { id: "lookup-1", name: "lookup", input: { query: "Synthetic" } }, + ], + providerMetadata, + }, + { text: "Done" }, + ], { + provider: "google", + modelId: "gemini-3.5-flash", + only: "stream", + reconcileProviderMetadata: ({ providerMetadata, suppressedToolCalls }) => + reconcileGoogleProviderMetadata(providerMetadata, suppressedToolCalls), + }); + const modelId = "veryfront-cloud/google/gemini-3.5-flash"; + const allowedModelIds = new Set([modelId]); + const forward = new TransformStream(); + const backward = new TransformStream(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model }), + }); + try { + const resolve = await createExecutorModelRuntimeResolver({ channel: caller, allowedModelIds }); + const assistant = agent({ + model: modelId, + system: "Use the lookup tool.", + tools: { + lookup: tool({ + id: "lookup", + description: "Look up a synthetic value", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: ({ query }) => ({ value: query }), + }), + }, + maxSteps: 2, + resolveModelTransport: () => ({ model: resolve(modelId)! }), + }); + const body = await (await assistant.stream({ input: "Look up the synthetic value" })) + .toDataStreamResponse().text(); + assertEquals(model.callCount, 2, body); + const continuation = model.calls[1]?.prompt.find((message) => message.role === "assistant"); + assertEquals(continuation?.providerMetadata, { + google: { rawAssistantParts: [surviving], rawAssistantPartIndexes: [1] }, + }); + assertStringIncludes(body, "Done"); + assertEquals(body.includes("synthetic-surviving-signature"), false); + } finally { + caller.close(); + await broker.closed; + } +}); From ce78250d8e7eba1d58b79f806088b7d9d47fa4c3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 16:56:51 +0200 Subject: [PATCH 006/194] feat(agent): bridge hosted executor streams and required model audit --- .../hosted/chat-runtime-agent-adapter.ts | 88 +- src/agent/hosted/durable-chat-run-start.ts | 13 +- .../hosted/executor-agent-bridge.test.ts | 823 ++++++++++++++++++ src/agent/hosted/executor-agent-bridge.ts | 221 +++++ .../hosted/executor-agent-errors.test.ts | 108 +++ src/agent/hosted/executor-agent-schema.ts | 174 ++++ src/agent/hosted/executor-model-bridge.ts | 67 +- .../hosted/executor-model-dispatch-options.ts | 142 +++ .../hosted/executor-model-dispatch.test.ts | 820 +++++++++++++++++ src/agent/hosted/executor-model-dispatch.ts | 204 +++++ src/agent/service/routes.ts | 8 +- .../streaming/executor-data-producers.test.ts | 187 ++++ src/agent/streaming/executor-data-schema.ts | 177 ++++ .../streaming/executor-data-stream.test.ts | 141 +++ src/agent/streaming/executor-data-stream.ts | 79 ++ src/runtime/model-call-context-request.ts | 137 +++ src/runtime/runtime-bridge.ts | 109 +-- .../executor-model-dispatch-contract.test.ts | 225 +++++ 18 files changed, 3572 insertions(+), 151 deletions(-) create mode 100644 src/agent/hosted/executor-agent-bridge.test.ts create mode 100644 src/agent/hosted/executor-agent-bridge.ts create mode 100644 src/agent/hosted/executor-agent-errors.test.ts create mode 100644 src/agent/hosted/executor-agent-schema.ts create mode 100644 src/agent/hosted/executor-model-dispatch-options.ts create mode 100644 src/agent/hosted/executor-model-dispatch.test.ts create mode 100644 src/agent/hosted/executor-model-dispatch.ts create mode 100644 src/agent/streaming/executor-data-producers.test.ts create mode 100644 src/agent/streaming/executor-data-schema.ts create mode 100644 src/agent/streaming/executor-data-stream.test.ts create mode 100644 src/agent/streaming/executor-data-stream.ts create mode 100644 src/runtime/model-call-context-request.ts create mode 100644 tests/integration/agent/executor-model-dispatch-contract.test.ts diff --git a/src/agent/hosted/chat-runtime-agent-adapter.ts b/src/agent/hosted/chat-runtime-agent-adapter.ts index b3c8314b7b..c3e33808d8 100644 --- a/src/agent/hosted/chat-runtime-agent-adapter.ts +++ b/src/agent/hosted/chat-runtime-agent-adapter.ts @@ -56,48 +56,9 @@ export function createHostedChatRuntimeAgentAdapter( input: HostedChatRuntimeAgentAdapterInput, ): HostedChatRuntimeAgent { const runStream = input.runStream ?? ((operation) => operation()); - return { stream: async (streamInput): Promise => { - let publishDataEvent = (_event: ToolExecutionDataEvent) => {}; - const streamResponse = await runStream(() => - runWithEffectiveSourceIntegrationPolicy( - input.sourceIntegrationPolicy, - async () => { - const projectContext = input.resolveProjectContext?.() ?? { - projectId: input.projectId, - projectSlug: input.projectSlug, - }; - const response = await input.runtimeAgent.stream({ - messages: streamInput.messages, - ...(input.maxOutputTokens !== undefined - ? { maxOutputTokens: input.maxOutputTokens } - : {}), - context: { - ...(input.runId ? { runId: input.runId } : {}), - ...(input.agentId ? { agentId: input.agentId } : {}), - ...(input.conversationId ? { conversationId: input.conversationId } : {}), - ...(projectContext?.projectId ? { projectId: projectContext.projectId } : {}), - ...(projectContext?.projectSlug ? { projectSlug: projectContext.projectSlug } : {}), - abortSignal: streamInput.abortSignal, - publishDataEvent: (event: ToolExecutionDataEvent) => publishDataEvent(event), - }, - }); - return response.toDataStreamResponse(); - }, - ) - ); - - if (!streamResponse.body) { - throw AGENT_ERROR.create({ detail: "Agent runtime returned an empty stream body" }); - } - - const stream = createToolExecutionDataEventBridgeStream({ - baseStream: streamResponse.body, - installPublisher: (nextPublishDataEvent) => { - publishDataEvent = nextPublishDataEvent; - }, - }); + const stream = await createHostedChatRuntimeDataStream(input, streamInput, runStream); return { steps: Promise.resolve([]), @@ -126,3 +87,50 @@ export function createHostedChatRuntimeAgentAdapter( }, }; } + +/** @internal Start the runtime data stream without installing broker UI callbacks. */ +export async function createHostedChatRuntimeDataStream( + input: HostedChatRuntimeAgentAdapterInput, + streamInput: Parameters[0], + runStream: HostedChatRuntimeAgentAdapterRunner = input.runStream ?? ((operation) => operation()), +): Promise> { + let publishDataEvent = (_event: ToolExecutionDataEvent) => {}; + const streamResponse = await runStream(() => + runWithEffectiveSourceIntegrationPolicy( + input.sourceIntegrationPolicy, + async () => { + const projectContext = input.resolveProjectContext?.() ?? { + projectId: input.projectId, + projectSlug: input.projectSlug, + }; + const response = await input.runtimeAgent.stream({ + messages: streamInput.messages, + ...(input.maxOutputTokens !== undefined + ? { maxOutputTokens: input.maxOutputTokens } + : {}), + context: { + ...(input.runId ? { runId: input.runId } : {}), + ...(input.agentId ? { agentId: input.agentId } : {}), + ...(input.conversationId ? { conversationId: input.conversationId } : {}), + ...(projectContext?.projectId ? { projectId: projectContext.projectId } : {}), + ...(projectContext?.projectSlug ? { projectSlug: projectContext.projectSlug } : {}), + abortSignal: streamInput.abortSignal, + publishDataEvent: (event: ToolExecutionDataEvent) => publishDataEvent(event), + }, + }); + return response.toDataStreamResponse(); + }, + ) + ); + + if (!streamResponse.body) { + throw AGENT_ERROR.create({ detail: "Agent runtime returned an empty stream body" }); + } + + return createToolExecutionDataEventBridgeStream({ + baseStream: streamResponse.body, + installPublisher: (nextPublishDataEvent) => { + publishDataEvent = nextPublishDataEvent; + }, + }); +} diff --git a/src/agent/hosted/durable-chat-run-start.ts b/src/agent/hosted/durable-chat-run-start.ts index f34e391956..fd37564d3d 100644 --- a/src/agent/hosted/durable-chat-run-start.ts +++ b/src/agent/hosted/durable-chat-run-start.ts @@ -23,8 +23,10 @@ export type HostedDurableRunSetupErrorStatusCode = | 403 | 404 | 408 + | 409 | 413 | 429 + | 499 | 500 | 501 | 502 @@ -84,7 +86,8 @@ function isDurableRunSetupErrorStatusCode( status: number | undefined, ): status is HostedDurableRunSetupErrorStatusCode { return status === 400 || status === 402 || status === 403 || status === 404 || - status === 408 || status === 413 || status === 429 || status === 500 || + status === 408 || status === 409 || status === 413 || status === 429 || status === 499 || + status === 500 || status === 501 || status === 502 || status === 503; } @@ -97,12 +100,16 @@ function isDurableRunSetupErrorStatusCode( * parser's EXTERNAL_SERVICE_ERROR default. The error is snapshotted once * because proxied errors can pass the guard yet throw from field getters. */ -function classifyDurableRunSetupError(error: unknown): { code: string; status?: number } { +export function classifyHostedChatSetupError( + error: unknown, +): { code: string; status?: number; message: string } { const snapshot = snapshotVeryfrontError(error); if (snapshot) { return { code: snapshot.slug.toUpperCase().replaceAll("-", "_"), status: snapshot.status, + // The registry title is stable; request/provider details stay out of SSE. + message: snapshot.title, }; } @@ -319,7 +326,7 @@ export async function executeHostedDurableChatRun( ); } - const { code, status } = classifyDurableRunSetupError(error); + const { code, status } = classifyHostedChatSetupError(error); const response = resolveHostedDurableRunSetupErrorResponse({ code, status, diff --git a/src/agent/hosted/executor-agent-bridge.test.ts b/src/agent/hosted/executor-agent-bridge.test.ts new file mode 100644 index 0000000000..5dc7bc5580 --- /dev/null +++ b/src/agent/hosted/executor-agent-bridge.test.ts @@ -0,0 +1,823 @@ +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import type { HostedChatRuntimeStreamInput } from "./chat-runtime-contract.ts"; +import { + createHostedChatRuntimeAgentAdapter, + createHostedChatRuntimeDataStream, +} from "./chat-runtime-agent-adapter.ts"; +import { + createExecutorAgentOperations, + createExecutorHostedChatRuntimeAgent, +} from "./executor-agent-bridge.ts"; +import { ExecutorAgentError } from "./executor-agent-schema.ts"; + +const handle = "prepared-synthetic-runtime"; +const sourceIntegrationPolicy = { schemaVersion: 1, mode: "unrestricted" } as const; +const messages: HostedChatRuntimeStreamInput["messages"] = [{ + id: "message-1", + role: "user", + parts: [{ type: "text", text: "Synthetic question" }], + timestamp: 1, +}]; +const encoder = new TextEncoder(); +const sse = (events: JsonValue[]) => + new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + controller.close(); + }, + }); +async function collect(stream: AsyncIterable) { + const result: ChatUiMessageChunk[] = []; + for await (const chunk of stream) result.push(chunk); + return result; +} +function pair(operations: ReadonlyMap, options: { + maxConcurrentCalls?: number; + executorCancellationTimeoutMs?: number; +} = {}) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const binding = { allocationId: "allocation", generation: 1, invocationId: "invocation" }; + const broker = createExecutorChannel({ + binding, + maxConcurrentCalls: options.maxConcurrentCalls, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const executor = createExecutorChannel({ + binding, + operations, + maxConcurrentCalls: options.maxConcurrentCalls, + cancellationTimeoutMs: options.executorCancellationTimeoutMs, + transport: { readable: forward.readable, writable: backward.writable }, + }); + return { + broker, + executor, + async close() { + broker.close(); + await executor.closed; + }, + }; +} + +describe("executor hosted agent bridge", () => { + it("matches legacy chunks and retains context, reasoning, tool failures, progress, usage and broker callbacks", async () => { + const events: JsonValue[] = [ + { type: "message-start", messageId: "executor-message" }, + { type: "data-veryfront.runtime_context", data: { projectId: "project-1" } }, + { type: "reasoning-start", id: "reasoning-1" }, + { type: "reasoning-delta", id: "reasoning-1", delta: "Synthetic reasoning" }, + { type: "reasoning-end", id: "reasoning-1" }, + { type: "tool-input-start", toolCallId: "tool-1", toolName: "lookup", dynamic: true }, + { type: "tool-input-delta", toolCallId: "tool-1", inputTextDelta: '{"query":"x"}' }, + { + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "lookup", + input: { query: "x" }, + }, + { type: "data-tool-progress", data: { step: 1 } }, + { type: "tool-output-error", toolCallId: "tool-1", errorText: "Try another source" }, + { type: "text-delta", id: "text-1", delta: "Synthetic answer" }, + { type: "text-end", id: "text-1" }, + { type: "step-end" }, + { + type: "message-finish", + finishReason: "stop", + totalUsage: { inputTokens: 2, outputTokens: 3, reasoningTokens: 1, costUsd: 0.1 }, + }, + ]; + type RuntimeInput = Parameters< + Parameters[0]["runtimeAgent"]["stream"] + >[0]; + let received: RuntimeInput | undefined; + const adapterInput = { + sourceIntegrationPolicy, + runId: "run-1", + agentId: "agent-1", + projectId: "project-1", + maxOutputTokens: 100, + runtimeAgent: { + stream(input: RuntimeInput) { + received = input; + return Promise.resolve({ toDataStreamResponse: () => new Response(sse(events)) }); + }, + }, + }; + let cleaned = 0; + const channels = pair(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: (input) => createHostedChatRuntimeDataStream(adapterInput, input), + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + })); + try { + const input = { messages, abortSignal: new AbortController().signal }; + const remote = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream(input); + assertEquals(await remote.steps, []); + const callbackOrder: string[] = []; + const metadata = { modelId: "synthetic-model" }; + const chunks = await collect(remote.toUIMessageStream({ + generateMessageId: () => "broker-message", + sendReasoning: true, + messageMetadata({ part }) { + callbackOrder.push("metadata"); + assertEquals(part.totalUsage.inputTokens, 2); + return metadata; + }, + async onFinish(event) { + callbackOrder.push("persist"); + await Promise.resolve(); + assertEquals(event.responseMessage.id, "broker-message"); + assertEquals(event.responseMessage.metadata, metadata); + callbackOrder.push("persisted"); + }, + })); + const remoteReceived = received; + const legacy = await createHostedChatRuntimeAgentAdapter(adapterInput).stream(input); + assertEquals( + chunks, + await collect(legacy.toUIMessageStream({ + generateMessageId: () => "broker-message", + sendReasoning: true, + messageMetadata: () => metadata, + })), + ); + assertEquals(callbackOrder, ["metadata", "persist", "persisted"]); + assertEquals(remoteReceived?.messages, messages); + assertEquals(remoteReceived?.context?.projectId, "project-1"); + assertEquals(remoteReceived?.maxOutputTokens, 100); + assertEquals(cleaned, 1); + assertEquals(channels.broker.signal.aborted, false); + } finally { + await channels.close(); + } + }); + + it("does not yield finish before the broker persistence callback settles", async () => { + const persisted = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => Promise.resolve(sse([{ type: "message-finish" }])), + }), + ); + try { + const runtime = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + const chunks: ChatUiMessageChunk[] = []; + const consume = (async () => { + for await ( + const chunk of runtime.toUIMessageStream({ + onFinish: () => { + entered.resolve(); + return persisted.promise; + }, + }) + ) chunks.push(chunk); + })(); + await entered.promise; + assertEquals(chunks.some((chunk) => chunk.type === "finish"), false); + persisted.resolve(); + await consume; + assertEquals(chunks.at(-1)?.type, "finish"); + } finally { + persisted.resolve(); + await channels.close(); + } + }); + + it("keeps curated error codes and invokes onError locally", async () => { + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => + Promise.resolve( + sse([{ type: "error", error: "Synthetic error", code: "CONTEXT_LENGTH_EXCEEDED" }]), + ), + }), + ); + try { + const runtime = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + let code: string | undefined; + const chunks = await collect(runtime.toUIMessageStream({ + onError: (_error, context) => { + code = context?.code; + return "Broker error text"; + }, + })); + assertEquals(code, "CONTEXT_LENGTH_EXCEEDED"); + assert( + chunks.some((chunk) => chunk.type === "error" && chunk.errorText === "Broker error text"), + ); + } finally { + await channels.close(); + } + }); + + for ( + const input of [ + 'data: {"type":"text-delta","delta":42}\n\n', + 'data: {"type":"text-delta","delta":"unfinished"}\n\n', + 'data: {"type":"message-finish"}', + "data: not-json\n\n", + ] + ) { + it("rejects invalid or truncated source data without calling onFinish", async () => { + let cleaned = 0; + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => Promise.resolve(new Response(input).body!), + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + }), + ); + try { + const runtime = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + let finished = false; + await assertRejects(() => + collect(runtime.toUIMessageStream({ + onFinish: () => { + finished = true; + }, + })), ExecutorAgentError); + assertEquals(finished, false); + assertEquals(cleaned, 1); + } finally { + await channels.close(); + } + }); + } + + const invalidFrames: JsonValue[][] = [ + [{ type: "ready" }], + [{ type: "ready" }, { type: "complete" }, { + type: "event", + event: { type: "message-finish" }, + }], + [{ type: "ready" }, { + type: "event", + event: { type: "message-finish", credentials: "synthetic" }, + }, { type: "complete" }], + ]; + for (const frames of invalidFrames) { + it("validates remote frames and requires completion followed by normal end", async () => { + const channels = pair( + new Map([["agent.stream", { + mode: "stream", + async *handle(): AsyncIterable { + const payloads: JsonValue[] = frames; + for (const frame of payloads) yield frame; + }, + }]]), + ); + try { + const runtime = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + await assertRejects(() => collect(runtime.toUIMessageStream()), ExecutorAgentError); + } finally { + await channels.close(); + } + }); + } + + it("rejects oversized input before starting an operation and never truncates it", async () => { + let starts = 0; + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => { + starts++; + return Promise.resolve(sse([{ type: "message-finish" }])); + }, + }), + ); + try { + const error = await assertRejects( + () => + createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ + messages: [{ + ...messages[0]!, + parts: [{ type: "text", text: "x".repeat(1024 * 1024) }], + }], + abortSignal: new AbortController().signal, + }), + ExecutorAgentError, + ); + assert(error instanceof ExecutorAgentError); + assertEquals(error.code, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + assertEquals(error.status, 413); + assertEquals(starts, 0); + } finally { + await channels.close(); + } + }); + + it("cancels a pending read, cleans the local runtime once and keeps the channel open", async () => { + const cleaned = Promise.withResolvers(); + let cancellations = 0; + let localSignal: AbortSignal | undefined; + const channels = pair(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: (input) => { + localSignal = input.abortSignal; + return Promise.resolve( + new ReadableStream({ + cancel() { + cancellations++; + }, + }), + ); + }, + cleanup: () => { + cleaned.resolve(); + return Promise.resolve(); + }, + })); + try { + const controller = new AbortController(); + const agent = createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }); + const runtime = await agent.stream({ messages, abortSignal: controller.signal }); + const stream = runtime.toUIMessageStream()[Symbol.asyncIterator](); + await stream.next(); + const pending = assertRejects(() => stream.next()); + controller.abort(); + await pending; + await cleaned.promise; + assertEquals(localSignal?.aborted, true); + assertEquals(cancellations, 1); + assertEquals(channels.broker.signal.aborted, false); + await assertRejects( + () => agent.stream({ messages, abortSignal: new AbortController().signal }), + ExecutorAgentError, + ); + assertThrows(() => runtime.toUIMessageStream(), ExecutorAgentError); + } finally { + await channels.close(); + } + }); + + it("releases a stream when the consumer returns after the initial UI start chunk", async () => { + let cleaned = false; + const channels = pair(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => Promise.resolve(new ReadableStream()), + cleanup: () => { + cleaned = true; + return Promise.resolve(); + }, + })); + try { + const result = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + const iterator = result.toUIMessageStream()[Symbol.asyncIterator](); + assertEquals((await iterator.next()).value.type, "start"); + await iterator.return?.(); + // The close handshake is observable without a sleep or request-signal abort. + assertEquals(cleaned, true); + assertEquals(channels.broker.signal.aborted, false); + } finally { + await channels.close(); + } + }); + + it("rejects startup with a fixed code and no original exception text", async () => { + let cleaned = 0; + const channels = pair(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => + Promise.reject( + Object.assign(new Error("synthetic-private-diagnostic"), { code: "OVERLOADED_ERROR" }), + ), + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + })); + try { + const error = await assertRejects( + () => + createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }), + ExecutorAgentError, + ); + assert(error instanceof ExecutorAgentError); + assertEquals(error.code, "OVERLOADED_ERROR"); + assertEquals(error.status, 503); + assertEquals(error.message.includes("synthetic-private-diagnostic"), false); + assertEquals(cleaned, 1); + } finally { + await channels.close(); + } + }); + + it("rejects abnormal channel end after a complete frame", async () => { + const channels = pair( + new Map([["agent.stream", { + mode: "stream", + async *handle(): AsyncIterable { + yield { type: "ready" }; + yield { type: "event", event: { type: "message-finish" } }; + yield { type: "complete" }; + throw new Error("synthetic-private-diagnostic"); + }, + }]]), + ); + try { + const runtime = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + let finished = false; + await assertRejects(() => + collect(runtime.toUIMessageStream({ + onFinish: () => { + finished = true; + }, + })), ExecutorAgentError); + assertEquals(finished, false); + } finally { + await channels.close(); + } + }); + + it("cancels readiness promptly while retaining setup and late-body cancellation", async () => { + const entered = Promise.withResolvers(); + const source = Promise.withResolvers>(); + const cancelStarted = Promise.withResolvers(); + const lateCleanup = Promise.withResolvers(); + const runtimeCleaned = Promise.withResolvers(); + let cleaned = 0; + const operations = new Map(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => { + entered.resolve(); + return source.promise; + }, + cleanup: () => { + cleaned++; + runtimeCleaned.resolve(); + return Promise.resolve(); + }, + })); + operations.set("ping", { mode: "unary", handle: () => null }); + const channels = pair(operations, { maxConcurrentCalls: 1 }); + try { + const controller = new AbortController(); + const result = createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: controller.signal }); + const rejected = assertRejects(() => result, ExecutorAgentError); + await entered.promise; + controller.abort(); + await rejected; + assertEquals(cleaned, 0); + assertEquals(channels.broker.signal.aborted, false); + await assertRejects( + () => channels.broker.request("ping", null), + Error, + "concurrent call limit", + ); + source.resolve( + new ReadableStream({ + cancel() { + cancelStarted.resolve(); + return lateCleanup.promise; + }, + }), + ); + await cancelStarted.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(cleaned, 0); + await assertRejects( + () => channels.broker.request("ping", null), + Error, + "concurrent call limit", + ); + lateCleanup.resolve(); + await runtimeCleaned.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(cleaned, 1); + assertEquals(await channels.broker.request("ping", null), null); + } finally { + lateCleanup.resolve(); + source.resolve(sse([{ type: "message-finish" }])); + await channels.close(); + } + }); + + it("fences setup that never settles before its cancellation deadline", async () => { + const entered = Promise.withResolvers(); + const source = Promise.withResolvers>(); + const runtimeCleaned = Promise.withResolvers(); + let cleaned = 0; + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => { + entered.resolve(); + return source.promise; + }, + cleanup: () => { + cleaned++; + runtimeCleaned.resolve(); + return Promise.resolve(); + }, + }), + { executorCancellationTimeoutMs: 20 }, + ); + try { + const controller = new AbortController(); + const result = createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: controller.signal }); + const rejected = assertRejects(() => result, ExecutorAgentError); + await entered.promise; + controller.abort(); + await rejected; + await new Promise((resolve) => setTimeout(resolve, 50)); + assertEquals(channels.executor.signal.aborted, true); + assertEquals( + (await channels.executor.closed).message, + "Executor handler cancellation deadline exceeded", + ); + assertEquals(cleaned, 0); + } finally { + source.resolve(sse([{ type: "message-finish" }])); + await runtimeCleaned.promise; + await channels.close(); + } + }); + + it("releases a UI iterator returned before its first next call", async () => { + let cleaned = false; + const channels = pair(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => Promise.resolve(new ReadableStream()), + cleanup: () => { + cleaned = true; + return Promise.resolve(); + }, + })); + try { + const runtime = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + await runtime.toUIMessageStream()[Symbol.asyncIterator]().return?.(); + assertEquals(cleaned, true); + } finally { + await channels.close(); + } + }); + + it("rejects an unbound handle or extra authority fields before touching the prepared runtime", async () => { + let starts = 0; + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => { + starts++; + return Promise.resolve(sse([{ type: "message-finish" }])); + }, + }), + ); + try { + const invalidInputs: JsonValue[] = [ + { preparedRuntimeHandle: "other-runtime", messages: [] }, + { preparedRuntimeHandle: handle, messages: [], authToken: "synthetic" }, + ]; + for (const payload of invalidInputs) { + const frames: JsonValue[] = []; + for await (const frame of channels.broker.stream("agent.stream", payload)) { + frames.push(frame); + } + assertEquals(frames, [{ + type: "failure", + phase: "setup", + code: "EXECUTOR_AGENT_INVALID_INPUT", + }]); + } + assertEquals(starts, 0); + const result = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + await collect(result.toUIMessageStream()); + assertEquals(starts, 1); + } finally { + await channels.close(); + } + }); + + it("turns channel loss into a typed stream failure without finalizing success", async () => { + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => Promise.resolve(new ReadableStream()), + }), + ); + try { + const result = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + let finished = false; + const chunks = result.toUIMessageStream({ + onFinish: () => { + finished = true; + }, + })[Symbol.asyncIterator](); + await chunks.next(); + const rejected = assertRejects(() => chunks.next(), ExecutorAgentError); + channels.executor.close(); + await rejected; + assertEquals(finished, false); + } finally { + await channels.close(); + } + }); + + for (const condition of ["aborted", "closed", "capacity"] as const) { + it(`normalizes ${condition} setup failures before iteration`, async () => { + const held = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const channels = pair( + new Map([["hold", { + mode: "unary", + async handle() { + entered.resolve(); + await held.promise; + return null; + }, + }]]), + { maxConcurrentCalls: 1 }, + ); + let active: Promise | undefined; + try { + const controller = new AbortController(); + if (condition === "aborted") controller.abort(new Error("Synthetic caller abort")); + if (condition === "closed") { + channels.broker.close(); + await channels.broker.closed; + } + if (condition === "capacity") { + active = channels.broker.request("hold", null); + await entered.promise; + } + const error = await assertRejects( + () => + createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: controller.signal }), + ExecutorAgentError, + ); + assert(error instanceof ExecutorAgentError); + assertEquals( + error.code, + condition === "aborted" ? "ABORTED" : "EXECUTOR_AGENT_SETUP_FAILED", + ); + assertEquals(error.status, condition === "aborted" ? 499 : 500); + } finally { + held.resolve(); + await active?.catch(() => {}); + await channels.close(); + } + }); + } + + it("retains admission and runtime cleanup until source cancellation settles", async () => { + const sourceCleanup = Promise.withResolvers(); + const cancelStarted = Promise.withResolvers(); + let cleanupCalls = 0; + let cancelled = false; + let cancellation: Promise | undefined; + const operations = new Map(createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => + Promise.resolve( + new ReadableStream({ + cancel() { + cancelStarted.resolve(); + return sourceCleanup.promise; + }, + }), + ), + cleanup: () => { + cleanupCalls++; + return Promise.resolve(); + }, + })); + operations.set("ping", { mode: "unary", handle: () => null }); + const channels = pair(operations, { maxConcurrentCalls: 1 }); + try { + const result = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + cancellation = result.toUIMessageStream()[Symbol.asyncIterator]().return?.(); + void cancellation?.then(() => cancelled = true, () => cancelled = true); + await cancelStarted.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(cleanupCalls, 0); + assertEquals(cancelled, false); + await assertRejects( + () => channels.broker.request("ping", null), + Error, + "concurrent call limit", + ); + sourceCleanup.resolve(); + await cancellation; + assertEquals(cleanupCalls, 1); + assertEquals(await channels.broker.request("ping", null), null); + } finally { + sourceCleanup.resolve(); + await cancellation?.catch(() => {}); + await channels.close(); + } + }); + + it("enforces the handler deadline while source cancellation remains pending", async () => { + const sourceCleanup = Promise.withResolvers(); + const cancelStarted = Promise.withResolvers(); + let cleanupCalls = 0; + let cancellation: Promise | undefined; + const channels = pair( + createExecutorAgentOperations({ + preparedRuntimeHandle: handle, + startStream: () => + Promise.resolve( + new ReadableStream({ + cancel() { + cancelStarted.resolve(); + return sourceCleanup.promise; + }, + }), + ), + cleanup: () => { + cleanupCalls++; + return Promise.resolve(); + }, + }), + { executorCancellationTimeoutMs: 20 }, + ); + try { + const result = await createExecutorHostedChatRuntimeAgent({ + channel: channels.broker, + preparedRuntimeHandle: handle, + }).stream({ messages, abortSignal: new AbortController().signal }); + cancellation = result.toUIMessageStream()[Symbol.asyncIterator]().return?.(); + await cancelStarted.promise; + await new Promise((resolve) => setTimeout(resolve, 50)); + assertEquals(channels.executor.signal.aborted, true); + assertEquals( + (await channels.executor.closed).message, + "Executor handler cancellation deadline exceeded", + ); + assertEquals(cleanupCalls, 0); + await cancellation; + } finally { + sourceCleanup.resolve(); + await cancellation?.catch(() => {}); + await channels.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts new file mode 100644 index 0000000000..27f230e957 --- /dev/null +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -0,0 +1,221 @@ +import type { ExecutorChannel, ExecutorOperation } from "../executor/channel.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; +import { createChatUiMessageStreamFromDataStream } from "../streaming/chat-ui-message-stream.ts"; +import { readExecutorDataEvents } from "../streaming/executor-data-stream.ts"; +import { + getExecutorAgentStreamFrameSchema, + parseExecutorDataEvent, +} from "../streaming/executor-data-schema.ts"; +import type { + HostedChatRuntimeAgent, + HostedChatRuntimeStreamInput, +} from "./chat-runtime-contract.ts"; +import { + ExecutorAgentError, + executorAgentFailureCode, + executorAgentJson, + getExecutorAgentStreamInputSchema, + getExecutorPreparedRuntimeHandleSchema, + parseExecutorAgentData, +} from "./executor-agent-schema.ts"; + +async function startExecutorRuntimeStream( + start: () => Promise>, + signal: AbortSignal, +): Promise> { + signal.throwIfAborted(); + // The channel notifies the caller of cancellation immediately, but keeps + // admission until this handler settles. Never detach unfinished setup from + // that lifetime; a noncooperative setup is fenced by the handler deadline. + const stream = await start(); + if (signal.aborted) { + await stream.cancel().catch(() => {}); + throw new ExecutorAgentError("ABORTED"); + } + return stream; +} + +/** @internal Install in an executor with one already-prepared runtime. No Agent or credential crosses the channel. */ +export function createExecutorAgentOperations(options: { + preparedRuntimeHandle: string; + startStream: (input: HostedChatRuntimeStreamInput) => Promise>; + /** Executor-local runtime cleanup only. The broker owns channel/allocation disposal. */ + cleanup?: () => Promise; +}): ReadonlyMap { + const handle = parseExecutorAgentData( + getExecutorPreparedRuntimeHandleSchema(), + options.preparedRuntimeHandle, + ); + let started = false; + return new Map([["agent.stream", { + mode: "stream", + async *handle(value, context) { + let phase: "setup" | "stream" = "setup"; + let ownsRuntime = false; + let failure: JsonValue | undefined; + let stream: ReadableStream | undefined; + try { + const request = parseExecutorAgentData(getExecutorAgentStreamInputSchema(), value); + executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + if (request.preparedRuntimeHandle !== handle) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_INPUT"); + } + if (started) throw new ExecutorAgentError("EXECUTOR_AGENT_ALREADY_STARTED"); + started = true; + ownsRuntime = true; + context.signal.throwIfAborted(); + stream = await startExecutorRuntimeStream( + () => options.startStream({ messages: request.messages, abortSignal: context.signal }), + context.signal, + ); + phase = "stream"; + yield { type: "ready" }; + for await (const event of readExecutorDataEvents(stream, context.signal)) { + yield executorAgentJson({ type: "event", event }, "EXECUTOR_AGENT_INVALID_STREAM"); + } + } catch (error) { + failure = { + type: "failure", + phase, + code: context.signal.aborted ? "ABORTED" : executorAgentFailureCode( + error, + phase === "setup" ? "EXECUTOR_AGENT_SETUP_FAILED" : "EXECUTOR_AGENT_STREAM_FAILED", + ), + }; + } finally { + // A return while suspended at ready can precede acquisition of the + // SSE reader. Close that unconsumed source as well as the runtime. + if (stream && !stream.locked) await stream.cancel().catch(() => {}); + if (ownsRuntime) { + try { + await options.cleanup?.(); + } catch { + failure ??= { type: "failure", phase, code: "EXECUTOR_AGENT_STREAM_FAILED" }; + } + } + } + context.signal.throwIfAborted(); + yield failure ?? { type: "complete" }; + }, + }]]); +} + +function parseFrame(value: unknown) { + const result = getExecutorAgentStreamFrameSchema().safeParse(value); + if (!result.success) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + return result.data; +} + +/** @internal Proxy one prepared runtime while keeping UI conversion and finish persistence in the broker. */ +export function createExecutorHostedChatRuntimeAgent(options: { + channel: ExecutorChannel; + preparedRuntimeHandle: string; + timeoutMs?: number; +}): HostedChatRuntimeAgent { + const handle = parseExecutorAgentData( + getExecutorPreparedRuntimeHandleSchema(), + options.preparedRuntimeHandle, + ); + let started = false; + return { + async stream(input) { + let opening: AsyncIterableIterator | undefined; + try { + if (started) throw new ExecutorAgentError("EXECUTOR_AGENT_ALREADY_STARTED"); + const request = parseExecutorAgentData(getExecutorAgentStreamInputSchema(), { + preparedRuntimeHandle: handle, + messages: input.messages, + }); + const payload = executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + input.abortSignal.throwIfAborted(); + started = true; + opening = options.channel.stream("agent.stream", payload, { + signal: input.abortSignal, + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + }); + const first = await opening.next(); + if (first.done) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + const frame = parseFrame(first.value); + if (frame.type === "failure") throw new ExecutorAgentError(frame.code); + if (frame.type !== "ready") throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } catch (error) { + const release = opening?.return?.(); + if (input.abortSignal.aborted) { + // Respond without waiting for cancellation acknowledgement. The + // channel retains admission and applies the cleanup deadline. + void release?.catch(() => {}); + } else { + await release?.catch(() => {}); + } + throw error instanceof ExecutorAgentError ? error : new ExecutorAgentError( + input.abortSignal.aborted ? "ABORTED" : "EXECUTOR_AGENT_SETUP_FAILED", + ); + } + const iterator = opening; + let consumed = false; + return { + steps: Promise.resolve([]), + toUIMessageStream(streamOptions = {}) { + if (consumed) throw new ExecutorAgentError("EXECUTOR_AGENT_ALREADY_STARTED"); + consumed = true; + let terminal = false; + const stream = new ReadableStream({ + async pull(controller) { + try { + const next = await iterator.next(); + if (next.done) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + const frame = parseFrame(next.value); + if (frame.type === "event") { + const event = parseExecutorDataEvent(frame.event); + terminal ||= event.type === "message-finish" || event.type === "finish" || + event.type === "error"; + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + ); + } else if (frame.type === "complete") { + if (!terminal || !(await iterator.next()).done) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + controller.close(); + } else if (frame.type === "failure") { + throw new ExecutorAgentError(frame.code); + } else throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } catch (error) { + await iterator.return?.().catch(() => {}); + controller.error( + error instanceof ExecutorAgentError ? error : new ExecutorAgentError( + input.abortSignal.aborted ? "ABORTED" : "EXECUTOR_AGENT_STREAM_FAILED", + ), + ); + } + }, + async cancel() { + await iterator.return?.().catch(() => {}); + }, + }, { highWaterMark: 0 }); + try { + const chunks = createChatUiMessageStreamFromDataStream({ stream }, streamOptions) + [Symbol.asyncIterator](); + const uiIterator: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return this; + }, + next: () => chunks.next(), + async return() { + // Cancel before awaiting the converter: it may not have started, + // or its current next() may still be waiting for remote output. + await iterator.return?.().catch(() => {}); + return await chunks.return?.() ?? { done: true, value: undefined }; + }, + }; + return uiIterator; + } catch (error) { + void iterator.return?.().catch(() => {}); + throw error; + } + }, + }; + }, + }; +} diff --git a/src/agent/hosted/executor-agent-errors.test.ts b/src/agent/hosted/executor-agent-errors.test.ts new file mode 100644 index 0000000000..aeb5c11ab2 --- /dev/null +++ b/src/agent/hosted/executor-agent-errors.test.ts @@ -0,0 +1,108 @@ +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { VeryfrontError } from "#veryfront/errors"; +import { createDetachedRunTracker } from "../service/detached-run-tracker.ts"; +import { createHostedAgentServiceRouteSet } from "../service/routes.ts"; +import { executeHostedDurableChatRun } from "./durable-chat-run-start.ts"; +import type { ParsedHostedChatRequest } from "./chat-request-parser.ts"; +import { ExecutorAgentError } from "./executor-agent-schema.ts"; + +const cases: Array<{ code: ConstructorParameters[0]; status: number }> = + [ + { code: "PERMISSION_DENIED", status: 403 }, + { code: "CONTEXT_LENGTH_EXCEEDED", status: 413 }, + { code: "RATE_LIMITED", status: 429 }, + { code: "INSUFFICIENT_CREDITS", status: 402 }, + { code: "RESOURCE_LIMIT_EXCEEDED", status: 402 }, + { code: "OVERLOADED_ERROR", status: 503 }, + { code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", status: 402 }, + { code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", status: 502 }, + { code: "AI_PROVIDER_BILLING_ERROR", status: 502 }, + { code: "EXECUTOR_AGENT_INVALID_INPUT", status: 400 }, + { code: "EXECUTOR_AGENT_INPUT_TOO_LARGE", status: 413 }, + { code: "EXECUTOR_AGENT_ALREADY_STARTED", status: 409 }, + { code: "EXECUTOR_AGENT_SETUP_FAILED", status: 500 }, + { code: "EXECUTOR_AGENT_STREAM_FAILED", status: 502 }, + { code: "EXECUTOR_AGENT_INVALID_STREAM", status: 502 }, + { code: "PROJECT_SCHEMA_ERROR", status: 400 }, + { code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", status: 400 }, + { code: "OUTPUT_SCHEMA_NOT_CLOSED", status: 400 }, + { code: "EXTERNAL_SERVICE_ERROR", status: 502 }, + { code: "DURABLE_RUN_EVENT_PERSISTENCE_FAILED", status: 500 }, + { code: "ABORTED", status: 499 }, + ]; + +function durableRequest(): ParsedHostedChatRequest { + return { + userId: "synthetic-user", + authToken: "synthetic-application-token", + agentId: undefined, + messages: [], + validatedContext: { projectId: "synthetic-project", branchId: null }, + projectId: "synthetic-project", + conversationId: "00000000-0000-4000-8000-000000000001", + parentRunId: "synthetic-run", + upstreamParentConversationId: undefined, + upstreamParentRunId: undefined, + spawnedFromToolCallId: undefined, + model: undefined, + allowDelegation: undefined, + forwardedProps: undefined, + runtimeOverrides: undefined, + durableRootRun: { runId: "synthetic-run", messageId: "synthetic-message" }, + persistLatestUserMessageBeforeDurableRun: false, + }; +} + +describe("executor errors at hosted setup response boundaries", () => { + for (const { code, status } of cases) { + it(`preserves ${code} through durable setup`, async () => { + const error = new ExecutorAgentError(code); + const response = await executeHostedDurableChatRun({ + req: durableRequest(), + rawRequest: new Request("https://agent.example.test/api/runs", { method: "POST" }), + tracker: createDetachedRunTracker(), + prepareExecution: () => Promise.reject(error), + startDetachedExecution: () => Promise.reject(new Error("Unexpected detached execution")), + }); + assertEquals(response.status, status); + assertEquals(await response.json(), { errorCode: code }); + assert(error instanceof VeryfrontError); + assertEquals(error.toRFC9457().title, code); + }); + + it(`preserves ${code} through direct AG-UI setup`, async () => { + const routeSet = createHostedAgentServiceRouteSet({ + tracker: createDetachedRunTracker(), + authenticateRequest: () => + Promise.resolve({ authToken: "synthetic-application-token", userId: "synthetic-user" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + prepareExecution: () => Promise.reject(new ExecutorAgentError(code)), + streamExecutionToAgUiResponse: () => + Promise.reject(new Error("Unexpected streaming execution")), + startDetachedExecution: () => Promise.reject(new Error("Unexpected detached execution")), + }); + const response = await routeSet.handleAgUiRequest( + new Request("https://agent.example.test/api/ag-ui", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + threadId: "00000000-0000-4000-8000-000000000001", + runId: "synthetic-run", + state: {}, + messages: [], + tools: [], + context: [], + }), + }), + ); + assertEquals(response.status, status); + const body = await response.text(); + const event = body.split("\n").find((line) => line.startsWith("data:")); + assert(event); + const data = JSON.parse(event.slice(5)); + assertEquals(data.code, code); + assertEquals(data.message, code); + }); + } +}); diff --git a/src/agent/hosted/executor-agent-schema.ts b/src/agent/hosted/executor-agent-schema.ts new file mode 100644 index 0000000000..fde4d81dd6 --- /dev/null +++ b/src/agent/hosted/executor-agent-schema.ts @@ -0,0 +1,174 @@ +import type { InferSchema, Schema } 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 { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import { defineError, snapshotVeryfrontError, VeryfrontError } from "#veryfront/errors/types.ts"; +import { EXECUTOR_MAX_FRAME_BYTES } from "../executor/protocol.ts"; + +// Reserve the complete worst-case protocol envelope: two 128-character binding +// strings can each require six JSON bytes per character, plus numeric identities, +// request metadata, and the four-byte frame prefix. This is a current wire limit, +// not a claim that every 1 MiB application request fits after runtime preparation. +export const EXECUTOR_AGENT_MAX_PAYLOAD_BYTES = EXECUTOR_MAX_FRAME_BYTES - 2048; + +const failureStatus = { + EXECUTOR_AGENT_INVALID_INPUT: 400, + EXECUTOR_AGENT_INPUT_TOO_LARGE: 413, + EXECUTOR_AGENT_ALREADY_STARTED: 409, + EXECUTOR_AGENT_SETUP_FAILED: 500, + EXECUTOR_AGENT_STREAM_FAILED: 502, + EXECUTOR_AGENT_INVALID_STREAM: 502, + OVERLOADED_ERROR: 503, + CONTEXT_LENGTH_EXCEEDED: 413, + INSUFFICIENT_CREDITS: 402, + RESOURCE_LIMIT_EXCEEDED: 402, + RATE_LIMITED: 429, + PROJECT_SCHEMA_ERROR: 400, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL: 400, + OUTPUT_SCHEMA_NOT_CLOSED: 400, + AI_PROVIDER_SPEND_LIMIT_EXCEEDED: 402, + AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED: 502, + AI_PROVIDER_BILLING_ERROR: 502, + EXTERNAL_SERVICE_ERROR: 502, + PERMISSION_DENIED: 403, + DURABLE_RUN_EVENT_PERSISTENCE_FAILED: 500, + ABORTED: 499, +} as const; + +export const getExecutorAgentFailureCodeSchema = defineSchema((v) => + v.enum( + [ + "EXECUTOR_AGENT_INVALID_INPUT", + "EXECUTOR_AGENT_INPUT_TOO_LARGE", + "EXECUTOR_AGENT_ALREADY_STARTED", + "EXECUTOR_AGENT_SETUP_FAILED", + "EXECUTOR_AGENT_STREAM_FAILED", + "EXECUTOR_AGENT_INVALID_STREAM", + "OVERLOADED_ERROR", + "CONTEXT_LENGTH_EXCEEDED", + "INSUFFICIENT_CREDITS", + "RESOURCE_LIMIT_EXCEEDED", + "RATE_LIMITED", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", + "EXTERNAL_SERVICE_ERROR", + "PERMISSION_DENIED", + "DURABLE_RUN_EVENT_PERSISTENCE_FAILED", + "ABORTED", + ] as const, + ) +); +type FailureCode = InferSchema>; + +/** @internal Fixed diagnostics contain neither rejected inputs nor upstream error bodies. */ +export class ExecutorAgentError extends VeryfrontError { + constructor(readonly code: FailureCode) { + const definition = defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + status: failureStatus[code], + title: code, + }); + super(code, definition); + this.name = "ExecutorAgentError"; + } +} + +export function executorAgentFailureCode(error: unknown, fallback: FailureCode): FailureCode { + if (error instanceof ExecutorAgentError) return error.code; + if (error !== null && typeof error === "object") { + const descriptor = Object.getOwnPropertyDescriptor(error, "code"); + const explicit = getExecutorAgentFailureCodeSchema().safeParse(descriptor?.value); + if (explicit.success) return explicit.data; + } + const snapshot = snapshotVeryfrontError(error); + const code = snapshot?.slug.toUpperCase().replaceAll("-", "_") ?? parseProviderError(error).code; + const result = getExecutorAgentFailureCodeSchema().safeParse(code); + return result.success ? result.data : fallback; +} + +export const getExecutorPreparedRuntimeHandleSchema = defineSchema((v) => + v.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/) +); + +export const getExecutorAgentStreamInputSchema = defineSchema((v) => { + const json = getJsonValueSchema(); + const attachment = { + url: v.string(), + mediaType: v.string(), + filename: v.string().optional(), + uploadId: v.string().optional(), + uploadPath: v.string().optional(), + }; + const parts = v.union([ + v.object({ type: v.literal("text"), text: v.string() }).strict(), + v.object({ + type: v.literal("reasoning"), + text: v.string().optional(), + signature: v.string().optional(), + redactedData: v.string().optional(), + }).strict(), + v.object({ + type: v.string(), + toolCallId: v.string(), + toolName: v.string(), + args: v.record(v.string(), json), + }).strict(), + v.object({ + type: v.literal("tool-result"), + toolCallId: v.string(), + toolName: v.string(), + result: json, + }).strict(), + v.object({ type: v.literal("image"), ...attachment }).strict(), + v.object({ type: v.literal("file"), ...attachment }).strict(), + v.object({ + type: v.literal("source-url"), + sourceId: v.string(), + url: v.string(), + title: v.string().optional(), + }).strict(), + v.object({ + type: v.literal("source-document"), + sourceId: v.string(), + title: v.string(), + mediaType: v.string().optional(), + filename: v.string().optional(), + }).strict(), + ]); + return v.object({ + preparedRuntimeHandle: getExecutorPreparedRuntimeHandleSchema(), + messages: v.array( + v.object({ + id: v.string(), + role: v.enum(["system", "user", "assistant", "tool"] as const), + parts: v.array(parts).max(10_000), + timestamp: v.number(), + }).strict(), + ).max(10_000), + }).strict(); +}); + +export function parseExecutorAgentData(schema: Schema, input: unknown): T { + const result = schema.safeParse(input); + if (!result.success) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_INPUT"); + return result.data; +} + +/** Serialize schema-validated values; optional undefined properties are omitted. */ +export function executorAgentJson(input: unknown, oversized: FailureCode): JsonValue { + const encoded = JSON.stringify(input); + if ( + encoded === undefined || + new TextEncoder().encode(encoded).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + ) { + throw new ExecutorAgentError(oversized); + } + const snapshot = snapshotBoundedJsonValue(JSON.parse(encoded)); + if (!snapshot.success) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_INPUT"); + return snapshot.value; +} diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 0acde235db..941b849efa 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -25,6 +25,28 @@ import { parseExecutorModelData, } from "./executor-model-schema.ts"; +/** Broker-owned identity and owned snapshot for one validated model dispatch. */ +export interface ExecutorModelDispatch { + readonly identity: { + readonly binding: ExecutorOperationContext["binding"]; + readonly sequence: number; + }; + readonly mode: "generate" | "stream"; + readonly model: ExecutorModelMetadata; + readonly options: Omit; +} + +/** Recheck owner authority synchronously at the provider invocation boundary. */ +export interface ExecutorModelDispatchPermit { + assertActive(): void; +} + +/** Generic broker hook; hosted callers use the required persistence wrapper. */ +export type ExecutorModelDispatchGate = ( + request: ExecutorModelDispatch, + context: ExecutorOperationContext, +) => void | ExecutorModelDispatchPermit | Promise; + /** * Construct only in trusted ingress. The resolver closes over ingress-owned * authority; project discovery, extension registries, and credentials are not @@ -33,11 +55,14 @@ import { export function createExecutorModelBroker(options: { resolveModelRuntime: AgentModelRuntimeResolver | undefined; allowedModelIds: ReadonlySet; + beforeModelDispatch?: ExecutorModelDispatchGate; }): ReadonlyMap { const resolve = options.resolveModelRuntime; if (typeof resolve !== "function") throw new TypeError("Managed model resolver is required"); const allowed = executorModelIds(options.allowedModelIds); const models = new Map(); + const beforeModelDispatch = options.beforeModelDispatch; + let sequence = 0; const getModel = (id: string): ModelRuntime => { if (!allowed.has(id)) throw new TypeError("Managed model is not allowed"); const cached = models.get(id); @@ -51,10 +76,37 @@ export function createExecutorModelBroker(options: { const call = parseExecutorModelData(getExecutorModelCallSchema(), input); context.signal.throwIfAborted(); return { + modelId: call.modelId, model: getModel(call.modelId), - options: { ...call.options, abortSignal: context.signal } satisfies ModelRuntimeCallOptions, + options: call.options satisfies Omit, }; }; + const authorizeDispatch = async ( + call: ReturnType, + mode: "generate" | "stream", + context: ExecutorOperationContext, + ) => { + if (sequence === Number.MAX_SAFE_INTEGER) { + throw new TypeError("Managed model call limit exceeded"); + } + const callSequence = ++sequence; + if (beforeModelDispatch) { + const metadata = parseExecutorModelData( + getExecutorModelMetadataSchema(), + executorModelJson([modelMetadata(call.modelId, call.model)]), + )[0]!; + return await beforeModelDispatch({ + identity: { binding: { ...context.binding }, sequence: callSequence }, + mode, + model: metadata, + // The sink never shares mutable prompt/options with provider dispatch. + options: parseExecutorModelData( + getExecutorModelOptionsSchema(), + executorModelJson(call.options), + ), + }, context); + } + }; return new Map([ ["model.metadata", { mode: "unary", @@ -102,7 +154,13 @@ export function createExecutorModelBroker(options: { mode: "unary", async handle(input, context) { const call = parseCall(input, context); - const result = await call.model.doGenerate(call.options); + const permit = await authorizeDispatch(call, "generate", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + const result = await call.model.doGenerate({ + ...call.options, + abortSignal: context.signal, + }); // Only the neutral result fields leave the broker. Native request and // response objects, headers, and transport diagnostics are never copied. const data = executorModelJson({ @@ -121,7 +179,10 @@ export function createExecutorModelBroker(options: { mode: "stream", async *handle(input, context) { const call = parseCall(input, context); - const result = await call.model.doStream(call.options); + const permit = await authorizeDispatch(call, "stream", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + const result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); const reader = result.stream.getReader(); // Later reader.cancel() calls do not wait for the first call's provider cleanup. let cancellation: Promise | undefined; diff --git a/src/agent/hosted/executor-model-dispatch-options.ts b/src/agent/hosted/executor-model-dispatch-options.ts new file mode 100644 index 0000000000..93db2be365 --- /dev/null +++ b/src/agent/hosted/executor-model-dispatch-options.ts @@ -0,0 +1,142 @@ +import type { ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { + buildModelCallContextRequest, + resolveModelCallProvider, +} from "#veryfront/runtime/model-call-context-request.ts"; +import { DurableRunEventPersistenceError } from "../conversation/private-run-event.ts"; +import type { ExecutorModelDispatch } from "./executor-model-bridge.ts"; + +// First-party builders shallow-merge these request-body fields. Hosted calls +// supply their persisted content and controls through the neutral contract. +const PERSISTED_REQUEST_FIELDS = new Set([ + "prompt", + "messages", + "system", + "contents", + "systeminstruction", + "input", + "instructions", + "tools", + "functions", + "toolconfig", + "toolchoice", + "functioncall", + "mcpservers", + "maxtokens", + "maxcompletiontokens", + "maxoutputtokens", + "temperature", + "topp", + "topk", + "stop", + "stopsequences", + "seed", + "presencepenalty", + "frequencypenalty", + "reasoning", + "reasoningeffort", + "cachedcontent", + "previousresponseid", + "conversation", + "container", + "contextmanagement", + "stream", + "streamoptions", +]); +const GOOGLE_CONTROL_FIELDS = [ + "maxOutputTokens", + "temperature", + "topP", + "topK", + "stopSequences", + "seed", + "thinkingConfig", +] as const; +const GOOGLE_SCHEMA_FIELDS = new Set(["responseMimeType", "responseSchema", "responseJsonSchema"]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function refuseOverride(): never { + throw new DurableRunEventPersistenceError( + "Hosted provider options replace persisted model input", + ); +} + +/** Check request configuration paths, never names inside schema/tool/replay data. */ +export function assertPersistedModelOptions(call: ExecutorModelDispatch): void { + const provider = resolveModelCallProvider(call.model); + for (const [name, bucket] of Object.entries(call.options.providerOptions ?? {})) { + if (!isRecord(bucket)) continue; + for (const [field, value] of Object.entries(bucket)) { + const normalized = field.replace(/[-_]/g, "").toLowerCase(); + if (PERSISTED_REQUEST_FIELDS.has(normalized)) refuseOverride(); + if (normalized === "generationconfig") { + if (provider !== "google") refuseOverride(); + assertGoogleGenerationConfig(value, call.options); + } + if (normalized === "thinking" || normalized === "outputconfig") { + // The shared durable projector represents the canonical Anthropic + // bucket. Gateway aliases must not replace those represented values. + if (provider !== "anthropic" || name !== "anthropic") refuseOverride(); + if (normalized === "outputconfig" && isRecord(value) && value.effort !== undefined) { + const projected = buildModelCallContextRequest(call.model, call.options); + if (value.effort !== projected?.reasoning?.effort) refuseOverride(); + } + } + } + } +} + +function expectedGoogleThinking(reasoning: ModelRuntimeCallOptions["reasoning"]): unknown { + if (reasoning?.enabled !== true) return undefined; + // Match the first-party Google builder's neutral reasoning mapping. The + // offline provider-contract test checks this alongside the actual builder. + const thinkingBudget = reasoning.budgetTokens ?? + (reasoning.effort === "low" + ? 512 + : reasoning.effort === "high" + ? 8192 + : reasoning.effort === "max" + ? -1 + : 2048); + return { includeThoughts: true, thinkingBudget }; +} + +function equalControl(actual: unknown, expected: unknown): boolean { + if (Array.isArray(expected)) { + return Array.isArray(actual) && actual.length === expected.length && + expected.every((value, index) => actual[index] === value); + } + if (isRecord(expected)) { + return isRecord(actual) && Object.keys(actual).length === Object.keys(expected).length && + Object.entries(expected).every(([key, value]) => actual[key] === value); + } + return actual === expected; +} + +function assertGoogleGenerationConfig( + value: unknown, + options: ExecutorModelDispatch["options"], +): void { + if (!isRecord(value)) refuseOverride(); + // Google replaces the entire object. Missing controls erase neutral input + // just as conflicting values override it. Permit an exact control match + // while leaving native response schemas and their user-defined names intact. + const expected: Record = { + maxOutputTokens: options.maxOutputTokens, + temperature: options.temperature, + topP: options.topP, + topK: options.topK, + stopSequences: options.stopSequences?.length ? options.stopSequences : undefined, + seed: options.seed, + thinkingConfig: expectedGoogleThinking(options.reasoning), + }; + for (const field of GOOGLE_CONTROL_FIELDS) { + if (!equalControl(value[field], expected[field])) refuseOverride(); + } + for (const field of Object.keys(value)) { + if (!Object.hasOwn(expected, field) && !GOOGLE_SCHEMA_FIELDS.has(field)) refuseOverride(); + } +} diff --git a/src/agent/hosted/executor-model-dispatch.test.ts b/src/agent/hosted/executor-model-dispatch.test.ts new file mode 100644 index 0000000000..0ef69cf41d --- /dev/null +++ b/src/agent/hosted/executor-model-dispatch.test.ts @@ -0,0 +1,820 @@ +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 type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import type { + AgentRunEventSink, + AgentRunModelCallContextEvent, +} from "#veryfront/runtime/model-call-context.ts"; +import { runWithMandatoryRunEventSink } from "#veryfront/runtime/run-event-sink-context.ts"; +import { isPrivateConversationRunEvent } from "../conversation/private-run-event.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import type { ExecutorBinding } from "../executor/protocol.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, + type ExecutorModelDispatch, +} from "./executor-model-bridge.ts"; +import { createHostedExecutorModelBroker } from "./executor-model-dispatch.ts"; + +const modelId = "veryfront-cloud/openai/synthetic-model"; +const allowedModelIds = new Set([modelId]); +const binding = { allocationId: "allocation-test", generation: 1, invocationId: "invocation-test" }; +const prompt = [{ role: "user", content: [{ type: "text", text: "Synthetic prompt" }] }] as const; + +function model( + onCall: (options: ModelRuntimeCallOptions, mode: string) => void, +): ModelRuntime { + return { + provider: "veryfront-cloud", + modelProvider: "openai", + modelId: "synthetic-model", + doGenerate(options) { + onCall(options, "generate"); + return Promise.resolve({ content: [{ type: "text", text: "Synthetic answer" }] }); + }, + doStream(options) { + onCall(options, "stream"); + return Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: "Synthetic answer" }); + controller.close(); + }, + }), + }); + }, + }; +} + +function pair( + operations: ReadonlyMap, + actualBinding: ExecutorBinding = binding, + options: { maxConcurrentCalls?: number; brokerCancellationTimeoutMs?: number } = {}, +) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding: actualBinding, + maxConcurrentCalls: options.maxConcurrentCalls, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding: actualBinding, + maxConcurrentCalls: options.maxConcurrentCalls, + cancellationTimeoutMs: options.brokerCancellationTimeoutMs, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + }); + return { + caller, + broker, + async close() { + caller.close(); + await broker.closed; + }, + }; +} + +function scope(signal = new AbortController().signal, scopeBinding = binding) { + return { + binding: scopeBinding, + signal, + assertActive() { + signal.throwIfAborted(); + }, + }; +} + +async function proxy(channels: ReturnType) { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + return resolver(modelId)!; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("hosted executor model dispatch", () => { + it("persists the validated request projection before dispatch and isolates sink mutation", async () => { + const entered = Promise.withResolvers(); + const persisted = Promise.withResolvers(); + const events: AgentRunModelCallContextEvent[] = []; + const order: string[] = []; + const options: ModelRuntimeCallOptions = { + prompt: [{ + role: "system", + content: "Synthetic system", + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral", ttl: "5m" }, extra: "excluded" }, + }, + }, ...prompt], + tools: [{ + type: "function", + name: "lookup", + inputSchema: { type: "object", properties: { url: { type: "string" } } }, + }], + maxOutputTokens: 17, + temperature: 0.4, + topP: 0.9, + topK: 2, + stopSequences: ["STOP"], + seed: 2, + presencePenalty: 0.3, + frequencyPenalty: 0.1, + reasoning: { enabled: false }, + providerOptions: { openai: { service_tier: "auto" } }, + responseFormat: { type: "json" }, + }; + let dispatched: ModelRuntimeCallOptions | undefined; + const channels = pair(createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => + model((actual) => { + order.push("dispatch"); + dispatched = actual; + }), + runEventSink: async (event) => { + order.push("persist"); + events.push(structuredClone(event)); + entered.resolve(); + await persisted.promise; + event.messages.length = 0; + order.push("ack"); + }, + })); + try { + const runtime = await proxy(channels); + await runtime.prepare?.(); + assertEquals(events.length, 0); + const pending = runtime.doGenerate(options); + await entered.promise; + assertEquals(dispatched, undefined); + assertEquals(order, ["persist"]); + assert(isPrivateConversationRunEvent(events[0])); + assertEquals(events[0], { + type: "AGENT_RUN_MODEL_CALL_CONTEXT", + model: { id: "synthetic-model", modelProvider: "openai" }, + messages: [{ + role: "system", + content: "Synthetic system", + providerOptions: { anthropic: { cacheControl: { type: "ephemeral", ttl: "5m" } } }, + }, ...prompt], + tools: options.tools, + request: { + maxOutputTokens: 17, + temperature: 0.4, + topP: 0.9, + topK: 2, + stopSequences: ["STOP"], + seed: 2, + presencePenalty: 0.3, + frequencyPenalty: 0.1, + reasoning: { enabled: false }, + }, + }); + persisted.resolve(); + await pending; + assertEquals(order, ["persist", "ack", "dispatch"]); + assert(dispatched); + const { abortSignal: _signal, ...actual } = dispatched; + assertEquals(actual, options); + } finally { + persisted.resolve(); + await channels.close(); + } + }); + + it("persists effective Anthropic reasoning precedence without raw provider options", async () => { + const cases = [ + { + reasoning: { enabled: false }, + providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2048 } } }, + expected: { enabled: true, budgetTokens: 2048 }, + }, + { + reasoning: { enabled: false }, + providerOptions: { + anthropic: { thinking: { type: "adaptive" }, output_config: { effort: "high" } }, + }, + expected: { enabled: true, effort: "high" }, + }, + { + reasoning: { enabled: true, budgetTokens: 1024 }, + providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2048 } } }, + expected: { enabled: true, budgetTokens: 1024 }, + }, + ] as const; + for (const mode of ["generate", "stream"] as const) { + for (const testCase of cases) { + let event: AgentRunModelCallContextEvent | undefined; + const options = { + prompt, + reasoning: testCase.reasoning, + providerOptions: testCase.providerOptions, + }; + const channels = pair(createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + runEventSink: (value) => { + event = value; + }, + resolveModelRuntime: () => ({ + ...model((actual) => { + assertEquals(actual.reasoning, testCase.reasoning); + assertEquals(actual.providerOptions, testCase.providerOptions); + assertEquals(event?.request?.reasoning, testCase.expected); + }), + modelProvider: "anthropic", + modelId: "claude-synthetic", + }), + })); + try { + const runtime = await proxy(channels); + if (mode === "generate") await runtime.doGenerate(options); + else { + const { stream } = await runtime.doStream(options); + const reader = stream.getReader(); + while (!(await reader.read()).done) { /* Consume the provider stream. */ } + } + assertEquals(event?.model, { id: "claude-synthetic", modelProvider: "anthropic" }); + assertEquals(event?.request, { reasoning: testCase.expected }); + assert(event && !("providerOptions" in event)); + } finally { + await channels.close(); + } + } + } + }); + + it("rejects gateway thinking overrides that the canonical durable projection does not represent", async () => { + let events = 0; + let dispatches = 0; + const channels = pair(createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + runEventSink: () => { + events++; + }, + resolveModelRuntime: () => ({ ...model(() => dispatches++), modelProvider: "anthropic" }), + })); + try { + const runtime = await proxy(channels); + await assertRejects( + async () => + await runtime.doGenerate({ + prompt, + reasoning: { enabled: false }, + providerOptions: { + anthropic: { thinking: { type: "enabled", budget_tokens: 2048 } }, + "veryfront-cloud": { thinking: { type: "enabled", budget_tokens: 4096 } }, + }, + }), + Error, + "operation-failed", + ); + assertEquals(events, 0); + assertEquals(dispatches, 0); + } finally { + await channels.close(); + } + }); + + it("persists OpenAI default and normalized explicit reasoning from resolved metadata", async () => { + for ( + const [reasoning, expected] of [ + [undefined, { enabled: true, effort: "medium" }], + [{ enabled: true, effort: "max" }, { enabled: true, effort: "high" }], + ] as const + ) { + let event: AgentRunModelCallContextEvent | undefined; + const channels = pair(createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + runEventSink: (value) => { + event = value; + }, + resolveModelRuntime: () => ({ + ...model((actual) => { + assertEquals(actual.reasoning, reasoning); + assertEquals(event?.request?.reasoning, expected); + }), + modelId: "o3", + }), + })); + try { + const runtime = await proxy(channels); + await runtime.doGenerate({ prompt, ...(reasoning ? { reasoning } : {}) }); + assertEquals(event?.model, { id: "o3", modelProvider: "openai" }); + assertEquals(event?.request, { reasoning: expected }); + } finally { + await channels.close(); + } + } + }); + + it("rejects provider bucket overrides of persisted request fields before capture", async () => { + let events = 0; + let dispatches = 0; + const channels = pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + runEventSink: () => { + events++; + }, + resolveModelRuntime: () => model(() => dispatches++), + }), + ); + try { + const runtime = await proxy(channels); + for ( + const bucket of ["anthropic", "google", "openai", "openai-compatible", "veryfront-cloud"] + ) { + for ( + const [field, value] of Object.entries({ + messages: [], + system: "Other system", + contents: [], + systemInstruction: { parts: [] }, + input: [], + instructions: "Other instructions", + tools: [], + functions: [], + max_tokens: 99, + max_completion_tokens: 99, + max_output_tokens: 99, + temperature: 1, + top_p: 1, + top_k: 9, + stop: ["OTHER"], + stop_sequences: ["OTHER"], + seed: 9, + presence_penalty: 1, + frequency_penalty: 1, + reasoning_effort: "high", + reasoning: { effort: "high" }, + cachedContent: "other-context", + previous_response_id: "other-response", + conversation: "other-conversation", + stream: true, + }) + ) { + await assertRejects( + async () => + await runtime.doGenerate({ + prompt, + providerOptions: { [bucket]: { [field]: value } }, + }), + Error, + "operation-failed", + ); + } + } + assertEquals(events, 0); + assertEquals(dispatches, 0); + } finally { + await channels.close(); + } + }); + + it("permits schema properties but rejects generationConfig changes to captured controls", async () => { + const responseSchema = { + type: "OBJECT", + properties: { + messages: { type: "STRING" }, + auth: { type: "STRING" }, + temperature: { type: "NUMBER" }, + }, + }; + let events = 0; + let dispatches = 0; + const channels = pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + runEventSink: () => { + events++; + }, + resolveModelRuntime: () => ({ ...model(() => dispatches++), modelProvider: "google" }), + }), + ); + try { + const runtime = await proxy(channels); + for (const bucket of ["google", "veryfront-cloud"]) { + await runtime.doGenerate({ + prompt, + providerOptions: { [bucket]: { generationConfig: { responseSchema } } }, + }); + await runtime.doGenerate({ + prompt, + maxOutputTokens: 12, + temperature: 0.4, + providerOptions: { + [bucket]: { + generationConfig: { maxOutputTokens: 12, temperature: 0.4, responseSchema }, + }, + }, + }); + for ( + const generationConfig of [{ responseSchema }, { maxOutputTokens: 13 }, { + maxOutputTokens: 12, + temperature: 1, + }, { maxOutputTokens: 12, thinkingConfig: { thinkingBudget: 4096 } }] + ) { + await assertRejects( + async () => + await runtime.doGenerate({ + prompt, + maxOutputTokens: 12, + temperature: 0.4, + providerOptions: { [bucket]: { generationConfig } }, + }), + Error, + "operation-failed", + ); + } + } + assertEquals(events, 4); + assertEquals(dispatches, 4); + } finally { + await channels.close(); + } + }); + + it("requires an explicit sink and rejects failed persistence before generate or stream", async () => { + assertThrows(() => + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => model(() => {}), + runEventSink: undefined, + }) + ); + let dispatches = 0; + const channels = pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => model(() => dispatches++), + runEventSink: () => { + throw new Error("Synthetic persistence failure"); + }, + }), + ); + try { + const runtime = await proxy(channels); + await assertRejects( + async () => await runtime.doGenerate({ prompt }), + Error, + "operation-failed", + ); + await assertRejects( + async () => await runtime.doStream({ prompt }), + Error, + "operation-failed", + ); + assertEquals(dispatches, 0); + } finally { + await channels.close(); + } + }); + + it("rechecks call abort and owner lifetime after persistence acknowledges", async () => { + for (const cancelBy of ["call", "scope", "active"] as const) { + const entered = Promise.withResolvers(); + const persisted = Promise.withResolvers(); + const lifetime = new AbortController(); + const call = new AbortController(); + let active = true; + let dispatches = 0; + const channels = pair(createHostedExecutorModelBroker({ + allowedModelIds, + scope: { + binding, + signal: lifetime.signal, + assertActive() { + if (!active) throw new Error("Synthetic expired lifetime"); + }, + }, + resolveModelRuntime: () => model(() => dispatches++), + runEventSink: async () => { + entered.resolve(); + await persisted.promise; + }, + })); + try { + const runtime = await proxy(channels); + const pending = Promise.resolve(runtime.doGenerate({ prompt, abortSignal: call.signal })); + const rejected = assertRejects(() => pending); + await entered.promise; + if (cancelBy === "call") call.abort(); + else if (cancelBy === "scope") lifetime.abort(); + else active = false; + persisted.resolve(); + await rejected; + assertEquals(dispatches, 0); + } finally { + persisted.resolve(); + await channels.close(); + } + } + }); + + it("retains admission for a cancelled call until its original persistence settles", async () => { + const entered = Promise.withResolvers(); + const persisted = Promise.withResolvers(); + let dispatches = 0; + const channels = pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => model(() => dispatches++), + runEventSink: async () => { + entered.resolve(); + await persisted.promise; + }, + }), + binding, + { maxConcurrentCalls: 1 }, + ); + let returning: Promise | undefined; + try { + const abort = new AbortController(); + const iterator = channels.caller.stream( + "model.stream", + { modelId, options: { prompt: [] } }, + { signal: abort.signal }, + ); + const read = iterator.next(); + const cancelled = assertRejects(() => read, Error, "cancelled"); + await entered.promise; + abort.abort(); + await cancelled; + let released = false; + returning = iterator.return!().then(() => { + released = true; + }); + await tick(); + assertEquals(released, false); + await assertRejects( + () => channels.caller.request("model.metadata", {}), + Error, + "concurrent call limit", + ); + persisted.resolve(); + await returning; + await channels.caller.request("model.metadata", {}); + assertEquals(dispatches, 0); + } finally { + persisted.resolve(); + await returning?.catch(() => {}); + await channels.close(); + } + }); + + it("fences a cancelled handler while its sink remains pending past the cleanup deadline", async () => { + const entered = Promise.withResolvers(); + const persisted = Promise.withResolvers(); + let dispatches = 0; + const channels = pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => model(() => dispatches++), + runEventSink: async () => { + entered.resolve(); + await persisted.promise; + }, + }), + binding, + { maxConcurrentCalls: 1, brokerCancellationTimeoutMs: 20 }, + ); + let returning: Promise | undefined; + try { + const abort = new AbortController(); + const iterator = channels.caller.stream( + "model.stream", + { modelId, options: { prompt: [] } }, + { signal: abort.signal }, + ); + const read = iterator.next(); + const cancelled = assertRejects(() => read, Error, "cancelled"); + await entered.promise; + abort.abort(); + await cancelled; + returning = iterator.return!(); + void returning.catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 50)); + assertEquals(channels.broker.signal.aborted, true); + assertEquals( + (await channels.broker.closed).message, + "Executor handler cancellation deadline exceeded", + ); + await assertRejects( + () => channels.caller.request("model.metadata", {}), + Error, + "Executor channel closed", + ); + await assertRejects(async () => await returning); + assertEquals(dispatches, 0); + } finally { + persisted.resolve(); + await returning?.catch(() => {}); + await channels.close(); + } + }); + + it("waits for persistence before opening a provider stream", async () => { + const entered = Promise.withResolvers(); + const persisted = Promise.withResolvers(); + const order: string[] = []; + const channels = pair(createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => model((_options, mode) => order.push(mode)), + runEventSink: async () => { + order.push("persist"); + entered.resolve(); + await persisted.promise; + order.push("ack"); + }, + })); + try { + const runtime = await proxy(channels); + const pending = runtime.doStream({ prompt }); + await entered.promise; + assertEquals(order, ["persist"]); + persisted.resolve(); + const { stream } = await pending; + const reader = stream.getReader(); + assertEquals((await reader.read()).value, { type: "text-delta", delta: "Synthetic answer" }); + assertEquals((await reader.read()).done, true); + assertEquals(order, ["persist", "ack", "stream"]); + } finally { + persisted.resolve(); + await channels.close(); + } + }); + + it("rejects executor event substitution and assistant content outside the durable contract", async () => { + let events = 0; + let dispatches = 0; + const channels = pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(), + resolveModelRuntime: () => model(() => dispatches++), + runEventSink: () => { + events++; + }, + }), + ); + try { + const runtime = await proxy(channels); + for ( + const content of [ + [{ type: "reasoning" as const, text: "Synthetic reasoning" }], + [{ + type: "tool-result" as const, + toolCallId: "synthetic-call", + toolName: "lookup", + result: {}, + providerExecuted: true as const, + }], + ] + ) { + await assertRejects(async () => + await runtime.doGenerate({ prompt: [{ role: "assistant", content }] }) + ); + } + await assertRejects(() => + channels.caller.request("model.generate", { + modelId, + options: { prompt: [] }, + event: { type: "AGENT_RUN_MODEL_CALL_CONTEXT", messages: [] }, + runId: "other-run", + }) + ); + assertEquals(events, 0); + assertEquals(dispatches, 0); + } finally { + await channels.close(); + } + }); + + it("checks binding and owner lifetime before metadata and preparation", async () => { + let resolutions = 0; + let preparations = 0; + let active = true; + const operations = createHostedExecutorModelBroker({ + allowedModelIds, + scope: { + ...scope(), + assertActive() { + if (!active) throw new Error("Synthetic revoked lifetime"); + }, + }, + resolveModelRuntime: () => { + resolutions++; + return { + ...model(() => {}), + prepare: () => { + preparations++; + return Promise.resolve(); + }, + }; + }, + runEventSink: () => {}, + }); + const wrong = pair(operations, { ...binding, invocationId: "other-invocation" }); + try { + await assertRejects(() => proxy(wrong)); + assertEquals(resolutions, 0); + } finally { + await wrong.close(); + } + const channels = pair(operations); + try { + const runtime = await proxy(channels); + active = false; + await assertRejects(async () => await runtime.prepare?.()); + await assertRejects(() => channels.caller.request("model.metadata", {})); + assertEquals(resolutions, 1); + assertEquals(preparations, 0); + } finally { + await channels.close(); + } + }); + + it("keeps concurrent invocation sinks isolated from ambient event scope", async () => { + const seen: string[] = []; + let ambient = 0; + const channels = ["one", "two"].map((name) => { + const invocation = { ...binding, invocationId: name }; + const sink: AgentRunEventSink = async (event) => { + await tick(); + seen.push(`${name}:${event.messages[0]?.content}`); + }; + return pair( + createHostedExecutorModelBroker({ + allowedModelIds, + scope: scope(undefined, invocation), + runEventSink: sink, + resolveModelRuntime: () => model(() => seen.push(`${name}:dispatch`)), + }), + invocation, + ); + }); + try { + const runtimes = await Promise.all(channels.map(proxy)); + await runWithMandatoryRunEventSink(() => { + ambient++; + }, async () => { + await Promise.all( + runtimes.map((runtime, index) => + runtime.doGenerate({ prompt: [{ role: "system", content: `input-${index}` }] }) + ), + ); + }); + assertEquals(ambient, 0); + assertEquals(seen.filter((item) => item.startsWith("one:")), ["one:input-0", "one:dispatch"]); + assertEquals(seen.filter((item) => item.startsWith("two:")), ["two:input-1", "two:dispatch"]); + } finally { + await Promise.all(channels.map((channel) => channel.close())); + } + }); + + it("gives the generic gate a canonical host sequence and an independent request snapshot", async () => { + const calls: ExecutorModelDispatch[] = []; + let dispatches = 0; + const channels = pair(createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => + model((options) => { + dispatches++; + assertEquals(options.prompt, prompt); + }), + beforeModelDispatch: (request) => { + calls.push(request); + (request.options.prompt as unknown[]).length = 0; + }, + })); + try { + const runtime = await proxy(channels); + await runtime.doGenerate({ prompt }); + const { stream } = await runtime.doStream({ prompt }); + const reader = stream.getReader(); + while (!(await reader.read()).done) { /* Consume the bounded stream. */ } + assertEquals( + calls.map((call) => [call.identity.binding, call.identity.sequence, call.mode]), + [[binding, 1, "generate"], [binding, 2, "stream"]], + ); + assertEquals(dispatches, 2); + } finally { + await channels.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-model-dispatch.ts b/src/agent/hosted/executor-model-dispatch.ts new file mode 100644 index 0000000000..30a3bf4cd5 --- /dev/null +++ b/src/agent/hosted/executor-model-dispatch.ts @@ -0,0 +1,204 @@ +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { + buildModelCallContextRequest, + resolveModelCallProvider, +} from "#veryfront/runtime/model-call-context-request.ts"; +import type { + AgentRunEventSink, + AgentRunModelCallContextEvent, + ModelCallMessage, +} from "#veryfront/runtime/model-call-context.ts"; +import { + DurableRunEventPersistenceError, + isPrivateConversationRunEvent, +} from "../conversation/private-run-event.ts"; +import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; +import type { AgentModelRuntimeResolver } from "../runtime/model-transport.ts"; +import { createExecutorModelBroker, type ExecutorModelDispatch } from "./executor-model-bridge.ts"; +import { executorModelJson, parseExecutorModelData } from "./executor-model-schema.ts"; +import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; + +/** Ingress-owned invocation authority. The sink already owns its exact run identity. */ +export interface HostedExecutorModelScope { + readonly binding: ExecutorBinding; + readonly signal: AbortSignal; + /** Throws when owner authority is inactive, including while persistence is pending. */ + readonly assertActive: () => void; +} + +/** + * Hosted model operations require a captured, acknowledging run event sink. + * Use the durable run sink backed by the trusted root mirror. No handler reads + * caller AsyncLocalStorage or accepts executor-authored event/run identity. + * Metadata and preparation check invocation authority but emit no call event. + */ +export function createHostedExecutorModelBroker(input: { + resolveModelRuntime: AgentModelRuntimeResolver | undefined; + allowedModelIds: ReadonlySet; + scope: HostedExecutorModelScope; + runEventSink: AgentRunEventSink | undefined; +}): ReadonlyMap { + const sink = input.runEventSink; + if (typeof sink !== "function") { + throw new DurableRunEventPersistenceError("Hosted model dispatch requires a run event sink"); + } + const binding = parseExecutorModelData(getExecutorBindingSchema(), input.scope.binding); + const lifetime = input.scope.signal; + const assertActive = input.scope.assertActive; + if (!(lifetime instanceof AbortSignal) || typeof assertActive !== "function") { + throw new TypeError("Hosted model dispatch requires invocation authority"); + } + const assertScope = (context: ExecutorOperationContext) => { + if ( + context.binding.allocationId !== binding.allocationId || + context.binding.generation !== binding.generation || + context.binding.invocationId !== binding.invocationId + ) throw new TypeError("Hosted model invocation binding mismatch"); + assertActive(); + lifetime.throwIfAborted(); + context.signal.throwIfAborted(); + if (Date.now() >= context.deadline) { + throw new TypeError("Hosted model dispatch deadline exceeded"); + } + }; + const operations = createExecutorModelBroker({ + resolveModelRuntime: input.resolveModelRuntime, + allowedModelIds: input.allowedModelIds, + async beforeModelDispatch(request, context) { + assertScope(context); + assertPersistedModelOptions(request); + const event = createContextEvent(request); + // Resolution is the existing sink contract's persistence acknowledgement. + // A sink that only queues writes does not satisfy the hosted contract. + await acknowledgePersistence(() => sink(event), context.signal); + assertScope(context); + return { assertActive: () => assertScope(context) }; + }, + }); + const bindContext = (context: ExecutorOperationContext): ExecutorOperationContext => { + assertScope(context); + return { ...context, signal: AbortSignal.any([lifetime, context.signal]) }; + }; + return new Map([...operations].map(([name, operation]): [string, ExecutorOperation] => [ + name, + operation.mode === "unary" + ? { mode: "unary", handle: (value, context) => operation.handle(value, bindContext(context)) } + : { + mode: "stream", + async *handle(value, context) { + yield* operation.handle(value, bindContext(context)); + }, + }, + ])); +} + +async function acknowledgePersistence( + persist: () => void | Promise, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const aborted = Promise.withResolvers(); + const onAbort = () => aborted.reject(new TypeError("Hosted model persistence cancelled")); + signal.addEventListener("abort", onAbort, { once: true }); + const persistence = Promise.resolve().then(persist); + try { + await Promise.race([persistence, aborted.promise]); + signal.throwIfAborted(); + } finally { + signal.removeEventListener("abort", onAbort); + // The channel notifies cancelled callers independently. Keep the original + // write inside handler settlement so admission and cleanup deadlines still + // account for persistence that has not acknowledged or failed yet. + await persistence.catch(() => {}); + } +} + +/** + * Match the existing durable projection: neutral messages/tools/controls and + * validated system cache metadata. Provider options and assistant replay + * metadata remain excluded. The request uses the existing ModelCallRequest + * subset; toolChoice, responseFormat, and userId are not durable event fields. + * Reasoning and provider-result assistant content + * cannot be represented by that contract and refuse hosted dispatch. + * The broker's local call sequence is not a new durable event field. + */ +function createContextEvent(call: ExecutorModelDispatch): AgentRunModelCallContextEvent { + const options = call.options; + const modelProvider = resolveModelCallProvider(call.model); + const request = buildModelCallContextRequest(call.model, options); + const event: AgentRunModelCallContextEvent = { + type: "AGENT_RUN_MODEL_CALL_CONTEXT", + ...(call.model.modelId + ? { + model: { id: call.model.modelId, ...(modelProvider ? { modelProvider } : {}) }, + } + : {}), + messages: options.prompt.map(projectMessage), + ...(options.tools ? { tools: [...options.tools] } : {}), + ...(request ? { request } : {}), + }; + const snapshot = structuredClone(event); + if (!isPrivateConversationRunEvent(executorModelJson(snapshot))) { + throw new DurableRunEventPersistenceError("Hosted model context cannot be persisted"); + } + return snapshot; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function projectSystemOptions( + options: Record | undefined, +): Record | undefined { + if (!options) return undefined; + const projected: Record = {}; + for (const [name, bucket] of Object.entries(options)) { + if (!name || !isRecord(bucket) || !isRecord(bucket.cacheControl)) continue; + const { type, ttl } = bucket.cacheControl; + if (type !== "ephemeral" || (ttl !== undefined && ttl !== "5m" && ttl !== "1h")) continue; + Object.defineProperty(projected, name, { + value: { cacheControl: { type, ...(ttl ? { ttl } : {}) } }, + enumerable: true, + }); + } + return Object.keys(projected).length ? projected : undefined; +} + +function projectMessage(message: ModelRuntimeCallOptions["prompt"][number]): ModelCallMessage { + switch (message.role) { + case "system": { + const providerOptions = projectSystemOptions(message.providerOptions); + return { + role: "system", + content: message.content, + ...(providerOptions ? { providerOptions } : {}), + }; + } + case "user": + return { role: "user", content: message.content.map((part) => ({ ...part })) }; + case "tool": + return { role: "tool", content: message.content.map((part) => ({ ...part })) }; + case "assistant": + return { + role: "assistant", + content: message.content.map((part) => { + if (part.type === "text") return { type: "text", text: part.text }; + if (part.type === "tool-call") { + return { + type: "tool-call", + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input, + ...(part.providerExecuted === undefined + ? {} + : { providerExecuted: part.providerExecuted }), + }; + } + throw new DurableRunEventPersistenceError("Hosted assistant content cannot be persisted"); + }), + }; + } +} diff --git a/src/agent/service/routes.ts b/src/agent/service/routes.ts index 34f8bda7fd..35289cbd22 100644 --- a/src/agent/service/routes.ts +++ b/src/agent/service/routes.ts @@ -1,4 +1,3 @@ -import { parseProviderError } from "../../chat/provider-errors.ts"; import { CONTROL_PLANE_RUN_STREAM_PATH } from "../../channels/control-plane.ts"; import type { AgentServiceRoute } from "./definition.ts"; import { createAgUiRunErrorEvent, createAgUiSseErrorResponse } from "../ag-ui/host-support.ts"; @@ -16,7 +15,10 @@ import { parseHostedChatRequestFromRequest, parseRuntimeAgentRunInvocationHostedChatRequestFromRequest, } from "../hosted/chat-request-parser.ts"; -import { executeHostedDurableChatRun } from "../hosted/durable-chat-run-start.ts"; +import { + classifyHostedChatSetupError, + executeHostedDurableChatRun, +} from "../hosted/durable-chat-run-start.ts"; import { type HostedServiceAuthenticatedRequest, HostedServiceAuthError, @@ -255,7 +257,7 @@ function createAgUiSetupErrorResponse(input: { ); } - const { code, status, message } = parseProviderError(input.error); + const { code, status, message } = classifyHostedChatSetupError(input.error); input.logger?.error("AG-UI request failed during setup", { errorCode: code, originalError: input.error instanceof Error ? input.error.message : String(input.error), diff --git a/src/agent/streaming/executor-data-producers.test.ts b/src/agent/streaming/executor-data-producers.test.ts new file mode 100644 index 0000000000..02ac2ecef2 --- /dev/null +++ b/src/agent/streaming/executor-data-producers.test.ts @@ -0,0 +1,187 @@ +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createMockResult } from "../runtime/chat-stream-handler.test-helpers.ts"; +import { createStreamState, processStream } from "../runtime/chat-stream-handler.ts"; +import { createStreamLifecycleLiveAdapter } from "./lifecycle/live-adapter.ts"; +import type { StreamSemanticEvent } from "./lifecycle/types.ts"; +import { StreamEventEmitter } from "./stream-events.ts"; +import { streamDataStreamEvents } from "./data-stream.ts"; +import { readExecutorDataEvents } from "./executor-data-stream.ts"; +import { createToolExecutionDataEventBridgeStream } from "./tool-execution-data-event-bridge.ts"; + +async function assertProducerStreamPreserved(stream: ReadableStream) { + const [local, remote] = stream.tee(); + const [expected, actual] = await Promise.all([ + Array.fromAsync(streamDataStreamEvents(local)), + Array.fromAsync(readExecutorDataEvents(remote, new AbortController().signal)), + ]); + assertEquals(actual, expected); + return actual; +} + +describe("executor data compatibility with runtime producers", () => { + it("preserves signed and redacted reasoning emitted by the existing stream handler", async () => { + // Uses the signed-reasoning provider fixture from chat-stream-handler.test.ts + // through the real producer, with the runtime's enclosing finish event. + const stream = new ReadableStream({ + async start(controller) { + const emitter = new StreamEventEmitter(controller); + emitter.emitStart("synthetic-message"); + await processStream( + createMockResult([ + { type: "reasoning-start", id: "thinking-0" }, + { type: "reasoning-delta", id: "thinking-0", delta: "Check evidence." }, + { + type: "reasoning-end", + id: "thinking-0", + signature: "synthetic-signature", + redactedData: "synthetic-redacted-data", + }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]), + createStreamState(), + controller, + new TextEncoder(), + "text-1", + ); + emitter.emitFinish(); + controller.close(); + }, + }); + const events = await assertProducerStreamPreserved(stream); + assert( + events.some((event) => + event.type === "reasoning-end" && "signature" in event && + event.signature === "synthetic-signature" && "redactedData" in event && + event.redactedData === "synthetic-redacted-data" + ), + ); + }); + + it("preserves every emitted live-lifecycle shape, including rejected and denied tools", async () => { + const adapter = createStreamLifecycleLiveAdapter({ textPartId: "text-part" }); + // Mirrors the protocol-shape fixtures in lifecycle/live-adapter.test.ts. + const semantic: StreamSemanticEvent[] = [ + { type: "text_start", id: "text:0" }, + { type: "text_content", id: "text:0", delta: "hello" }, + { type: "text_end", id: "text:0" }, + { type: "reasoning_start", id: "r1" }, + { type: "reasoning_content", id: "r1", delta: "thinking" }, + { + type: "reasoning_end", + id: "r1", + signature: "synthetic-signature", + redactedData: "synthetic-redacted-data", + }, + { type: "tool_input_start", toolCallId: "local-1", toolName: "create_file", dynamic: true }, + { type: "tool_input_content", toolCallId: "local-1", delta: '{"path":"a.md"}' }, + { + type: "tool_input_ready", + toolCallId: "local-1", + toolName: "create_file", + input: { path: "a.md" }, + }, + { + type: "tool_input_rejected", + toolCallId: "local-2", + toolName: "create_file", + reason: "malformed", + }, + { + type: "provider_tool_result", + toolCallId: "native-1", + toolName: "web_search", + output: { ok: true }, + isError: false, + providerExecuted: true, + dynamic: true, + preliminary: false, + }, + { + type: "provider_tool_result", + toolCallId: "native-2", + toolName: "web_search", + output: { error: "Synthetic tool error" }, + isError: true, + providerExecuted: true, + dynamic: true, + }, + { + type: "provider_tool_denied", + toolCallId: "native-3", + toolName: "web_search", + providerExecuted: true, + }, + { + type: "provider_tool_cancelled", + toolCallId: "native-4", + toolName: "web_search", + providerExecuted: true, + }, + { type: "custom", name: "synthetic", data: { progress: 1 } }, + ]; + const stream = new ReadableStream({ + start(controller) { + const emitter = new StreamEventEmitter(controller); + for (const [sequence, event] of semantic.entries()) { + for ( + const output of adapter.encode({ + class: "semantic", + sequence, + elapsedMs: sequence, + event, + }) + ) emitter.emit(output); + } + for ( + const output of adapter.encode({ + class: "telemetry", + sequence: semantic.length, + elapsedMs: semantic.length, + event: { type: "tool_input_status", toolCallId: "local-1", status: "pending_input" }, + }) + ) emitter.emit(output); + emitter.emitFinish(); + controller.close(); + }, + }); + const events = await assertProducerStreamPreserved(stream); + assert(events.some((event) => event.type === "tool-input-error")); + assert(events.some((event) => event.type === "tool-output-denied")); + assert(events.some((event) => event.type === "data-tool-call-status")); + }); + + it("preserves emitter tool-input errors and optional tool/custom payloads", async () => { + const stream = new ReadableStream({ + start(controller) { + const emitter = new StreamEventEmitter(controller); + emitter.emitStart("synthetic-message"); + emitter.emitStepStart(); + emitter.emitTextStart("text"); + emitter.emitTextDelta("text", "hello"); + emitter.emitTextEnd("text"); + emitter.emitToolInputStart("call-1", "read_file", true); + emitter.emitToolInputDelta("call-1", "{"); + emitter.emitToolInputError("call-1", "Synthetic incomplete input", true); + emitter.emitToolInputAvailable("call-2", "read_file", {}, true); + emitter.emitToolOutputAvailable("call-2", undefined, true); + emitter.emitToolOutputError("call-3", "Synthetic tool error", true); + emitter.emitStepEnd(); + emitter.emitFinish(); + controller.close(); + }, + }); + const bridged = createToolExecutionDataEventBridgeStream({ + baseStream: stream, + installPublisher(publish) { + publish({ type: "progress", name: "tool-progress", value: { progress: 1 } }); + publish({ type: "progress", name: "tool-pending" }); + publish({ type: "progress", data: { progress: 2 } }); + }, + }); + const events = await assertProducerStreamPreserved(bridged); + assert(events.some((event) => event.type === "tool-input-error" && !("toolName" in event))); + assert(events.some((event) => event.type === "tool-output-available" && !("output" in event))); + assert(events.some((event) => event.type === "data-tool-pending" && !("data" in event))); + }); +}); diff --git a/src/agent/streaming/executor-data-schema.ts b/src/agent/streaming/executor-data-schema.ts new file mode 100644 index 0000000000..ad9685fcb7 --- /dev/null +++ b/src/agent/streaming/executor-data-schema.ts @@ -0,0 +1,177 @@ +import { defineSchema, getJsonValueSchema, type JsonValue } from "#veryfront/schemas/index.ts"; +import { + ExecutorAgentError, + executorAgentFailureCode, + executorAgentJson, + getExecutorAgentFailureCodeSchema, +} from "../hosted/executor-agent-schema.ts"; + +const getUsageSchema = defineSchema((v) => { + const count = v.number().nonnegative().optional(); + return v.object({ + inputTokens: count, + outputTokens: count, + totalTokens: count, + promptTokens: count, + completionTokens: count, + reasoningTokens: count, + cachedInputTokens: count, + cacheReadInputTokens: count, + cacheCreationInputTokens: count, + billableInputTokens: count, + billableOutputTokens: count, + costUsd: count, + providerInputCostUsd: count, + providerOutputCostUsd: count, + providerCostUsd: count, + veryfrontInputChargeUsd: count, + veryfrontOutputChargeUsd: count, + veryfrontChargeUsd: count, + veryfrontBilledUsd: count, + costCredits: count, + inputTokenDetails: v.object({ + noCacheTokens: count, + cacheReadTokens: count, + cacheWriteTokens: count, + }).strict().optional(), + outputTokenDetails: v.object({ textTokens: count, reasoningTokens: count }).strict().optional(), + costSource: v.enum(["gateway", "missing", "partial"] as const).optional(), + billingMode: v.enum(["direct", "deferred"] as const).optional(), + usageCaptureStatus: v.enum(["complete", "partial", "missing"] as const).optional(), + }).strict(); +}); + +/** The runtime data-event vocabulary consumed by the hosted UI converter. */ +export const getExecutorDataEventSchema = defineSchema((v) => { + const json = getJsonValueSchema(); + const id = v.string().min(1); + const flags = { + providerExecuted: v.boolean().optional(), + dynamic: v.boolean().optional(), + preliminary: v.boolean().optional(), + }; + return v.union([ + v.object({ type: v.literal("message-start"), messageId: id.optional() }).strict(), + v.object({ + type: v.enum(["message-finish", "finish"] as const), + finishReason: v.string().optional(), + usage: getUsageSchema().optional(), + totalUsage: getUsageSchema().optional(), + object: json.optional(), + }).strict(), + v.object({ type: v.enum(["step-start", "step-end"] as const) }).strict(), + v.object({ + type: v.enum(["text-start", "text-end", "reasoning-start"] as const), + id: id.optional(), + }).strict(), + v.object({ + type: v.literal("reasoning-end"), + id: id.optional(), + signature: v.string().optional(), + redactedData: v.string().optional(), + }).strict(), + v.object({ + type: v.enum(["text-delta", "reasoning-delta"] as const), + id: id.optional(), + delta: v.string(), + }).strict(), + v.object({ type: v.literal("tool-input-start"), toolCallId: id, toolName: id, ...flags }) + .strict(), + v.object({ + type: v.literal("tool-input-delta"), + toolCallId: id, + inputTextDelta: v.string().optional(), + delta: v.string().optional(), + }).strict().refine( + (event) => event.inputTextDelta !== undefined || event.delta !== undefined, + "Tool input delta is required", + ), + v.object({ + type: v.literal("tool-input-available"), + toolCallId: id, + toolName: id, + input: json, + ...flags, + }).strict(), + // The runtime emitter omits undefined results during JSON serialization. + v.object({ + type: v.literal("tool-output-available"), + toolCallId: id, + output: json.optional(), + ...flags, + }) + .strict(), + v.object({ + type: v.literal("tool-input-error"), + toolCallId: id, + toolName: id.optional(), + input: json.optional(), + errorText: v.string(), + ...flags, + }).strict(), + v.object({ type: v.literal("tool-output-denied"), toolCallId: id }).strict(), + v.object({ + type: v.literal("tool-output-error"), + toolCallId: id, + errorText: v.string(), + ...flags, + }).strict(), + v.object({ type: v.literal("error"), error: v.string(), code: v.string().optional() }).strict(), + v.object({ type: v.literal("data"), data: v.record(v.string(), json) }).strict(), + v.object({ type: v.string().regex(/^data-.+$/), data: json.optional() }).strict(), + v.object({ + type: v.literal("source-url"), + sourceId: id.optional(), + url: v.string(), + title: v.string().optional(), + }).strict(), + v.object({ + type: v.literal("source-document"), + sourceId: id, + title: v.string(), + mediaType: v.string(), + filename: v.string().optional(), + }).strict(), + v.object({ + type: v.literal("file"), + url: v.string(), + mediaType: v.string(), + filename: v.string().optional(), + }).strict(), + ]); +}); + +export const getExecutorAgentStreamFrameSchema = defineSchema((v) => + v.discriminatedUnion("type", [ + v.object({ type: v.literal("ready") }).strict(), + v.object({ type: v.literal("event"), event: getExecutorDataEventSchema() }).strict(), + v.object({ type: v.literal("complete") }).strict(), + v.object({ + type: v.literal("failure"), + phase: v.enum(["setup", "stream"] as const), + code: getExecutorAgentFailureCodeSchema(), + }).strict(), + ]) +); + +export function parseExecutorDataEvent(input: unknown): JsonValue & { type: string } { + const result = getExecutorDataEventSchema().safeParse(input); + if (!result.success) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + const data = result.data.type === "error" + ? (() => { + const code = executorAgentFailureCode( + { code: "code" in result.data ? result.data.code : undefined }, + "EXECUTOR_AGENT_STREAM_FAILED", + ); + return { type: "error", code, error: code }; + })() + : result.data; + const value = executorAgentJson(data, "EXECUTOR_AGENT_INVALID_STREAM"); + if ( + value === null || typeof value !== "object" || Array.isArray(value) || + typeof value.type !== "string" + ) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + return { ...value, type: value.type }; +} diff --git a/src/agent/streaming/executor-data-stream.test.ts b/src/agent/streaming/executor-data-stream.test.ts new file mode 100644 index 0000000000..7fad877d9b --- /dev/null +++ b/src/agent/streaming/executor-data-stream.test.ts @@ -0,0 +1,141 @@ +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + encodeExecutorFrame, + EXECUTOR_MAX_FRAME_BYTES, + EXECUTOR_MAX_RETAINED_BYTES, +} from "../executor/protocol.ts"; +import { + EXECUTOR_AGENT_MAX_PAYLOAD_BYTES, + ExecutorAgentError, + executorAgentJson, +} from "../hosted/executor-agent-schema.ts"; +import { readExecutorDataEvents } from "./executor-data-stream.ts"; + +async function collect(stream: ReadableStream) { + const output: JsonValue[] = []; + for await (const event of readExecutorDataEvents(stream, new AbortController().signal)) { + output.push(event); + } + return output; +} + +describe("executor runtime data stream validation", () => { + it("reassembles split UTF-8 and coalesced events without changing text", async () => { + const events: JsonValue[] = [{ type: "text-delta", delta: "Synthetic å🙂\ntext" }, { + type: "message-finish", + }]; + const bytes = new TextEncoder().encode( + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), + ); + let offset = 0; + const stream = new ReadableStream({ + pull(controller) { + if (offset === bytes.length) controller.close(); + else controller.enqueue(bytes.subarray(offset, ++offset)); + }, + }); + assertEquals(await collect(stream), events); + }); + + for ( + const bytes of [ + new Uint8Array([0xff]), + new TextEncoder().encode( + `data: ${JSON.stringify({ type: "message-finish", totalUsage: { inputTokens: -1 } })}\n\n`, + ), + new TextEncoder().encode(`data: ${JSON.stringify({ type: "unknown-event" })}\n\n`), + new TextEncoder().encode( + `data: ${ + JSON.stringify({ type: "reasoning-end", id: "r1", signature: { invalid: true } }) + }\n\n`, + ), + new TextEncoder().encode( + `data: ${ + JSON.stringify({ type: "tool-input-error", toolCallId: "call-1", errorText: 42 }) + }\n\n`, + ), + new TextEncoder().encode( + `data: ${ + JSON.stringify({ + type: "tool-output-denied", + toolCallId: "call-1", + authorization: "synthetic", + }) + }\n\n`, + ), + new TextEncoder().encode( + `data: ${ + JSON.stringify({ + type: "text-delta", + delta: "x".repeat(EXECUTOR_AGENT_MAX_PAYLOAD_BYTES), + }) + }\n\n`, + ), + new Uint8Array(EXECUTOR_MAX_RETAINED_BYTES + 1), + ] + ) { + it("rejects invalid data and releases its reader", async () => { + let cancelled = 0; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + }, + cancel() { + cancelled++; + }, + }); + await assertRejects(() => collect(stream), ExecutorAgentError); + assertEquals(cancelled, 1); + assertEquals(stream.locked, false); + }); + } + + it("keeps the advertised payload within a worst-case channel envelope", () => { + const value = executorAgentJson( + "x".repeat(EXECUTOR_AGENT_MAX_PAYLOAD_BYTES - 2), + "EXECUTOR_AGENT_INPUT_TOO_LARGE", + ); + const frame = encodeExecutorFrame({ + version: 1, + binding: { + allocationId: "\u0000".repeat(128), + invocationId: "\u0000".repeat(128), + generation: Number.MAX_SAFE_INTEGER, + }, + sequence: Number.MAX_SAFE_INTEGER, + message: { + type: "request", + id: Number.MAX_SAFE_INTEGER, + operation: "agent.stream", + mode: "stream", + timeoutMs: 86_400_000, + value, + }, + }); + assert(frame.byteLength <= EXECUTOR_MAX_FRAME_BYTES); + }); + + it("preserves curated provider classifications without carrying raw diagnostic bodies", async () => { + for ( + const code of [ + "RATE_LIMITED", + "RESOURCE_LIMIT_EXCEEDED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + ] + ) { + const output = await collect( + new Response( + `data: ${JSON.stringify({ type: "error", code, error: "synthetic-private-body" })}\n\n`, + ).body!, + ); + assertEquals(output, [{ type: "error", code, error: code }]); + } + }); +}); diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts new file mode 100644 index 0000000000..da044a428b --- /dev/null +++ b/src/agent/streaming/executor-data-stream.ts @@ -0,0 +1,79 @@ +import { EXECUTOR_MAX_RETAINED_BYTES } from "../executor/protocol.ts"; +import { + EXECUTOR_AGENT_MAX_PAYLOAD_BYTES, + ExecutorAgentError, +} from "../hosted/executor-agent-schema.ts"; +import { parseExecutorDataEvent } from "./executor-data-schema.ts"; + +/** @internal Strict bounded SSE reader for the executor's runtime stream. */ +export async function* readExecutorDataEvents( + stream: ReadableStream, + signal: AbortSignal, +) { + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let pending = ""; + let terminal = false; + let completed = false; + // Only the first cancel promise includes asynchronous source cleanup. + let cancellation: Promise | undefined; + const cancel = () => { + if (!cancellation) { + cancellation = reader.cancel(); + void cancellation.catch(() => {}); + } + return cancellation; + }; + signal.addEventListener("abort", cancel, { once: true }); + try { + while (true) { + signal.throwIfAborted(); + const next = await reader.read(); + signal.throwIfAborted(); + if (next.done) break; + if ( + !(next.value instanceof Uint8Array) || next.value.byteLength > EXECUTOR_MAX_RETAINED_BYTES + ) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + // Process bounded slices even when the local producer coalesces many events. + for (let offset = 0; offset < next.value.byteLength; offset += 4096) { + pending += decoder.decode(next.value.subarray(offset, offset + 4096), { stream: true }); + let separator: number; + while ((separator = pending.indexOf("\n\n")) !== -1) { + const block = pending.slice(0, separator); + pending = pending.slice(separator + 2); + if (new TextEncoder().encode(block).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + const lines = block.split("\n"); + if (!lines.length || lines.some((line) => !line.startsWith("data:"))) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + const event = parseExecutorDataEvent( + JSON.parse(lines.map((line) => line.slice(5).trimStart()).join("\n")), + ); + terminal ||= event.type === "message-finish" || event.type === "finish" || + event.type === "error"; + yield event; + } + if (new TextEncoder().encode(pending).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + } + } + pending += decoder.decode(); + if (pending.length > 0 || !terminal) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + completed = true; + } catch (error) { + if (signal.aborted) throw new ExecutorAgentError("ABORTED"); + if (error instanceof ExecutorAgentError) throw error; + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } finally { + signal.removeEventListener("abort", cancel); + if (!completed) await cancel().catch(() => {}); + reader.releaseLock(); + } +} diff --git a/src/runtime/model-call-context-request.ts b/src/runtime/model-call-context-request.ts new file mode 100644 index 0000000000..e9a3f963ee --- /dev/null +++ b/src/runtime/model-call-context-request.ts @@ -0,0 +1,137 @@ +import type { + ModelRuntimeCallOptions, + RuntimeMetadata, + RuntimeReasoningOption, +} from "#veryfront/provider/types.ts"; +import { resolveOpenAIReasoningConfig } from "#veryfront/provider/shared/openai-reasoning.ts"; +import type { ModelCallRequest } from "./model-call-context.ts"; + +type ModelCallRuntimeMetadata = Pick; +type ModelCallRequestSource = Pick & { + providerOptions?: unknown; +}; + +const ReflectApply = Reflect.apply; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectHasOwn = Object.hasOwn; + +function readOwnEnumerableDataDescriptor( + value: unknown, + key: PropertyKey, +): PropertyDescriptor | undefined { + if (value === null || typeof value !== "object") return undefined; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = ReflectApply(ObjectGetOwnPropertyDescriptor, undefined, [value, key]) as + | PropertyDescriptor + | undefined; + } catch { + return undefined; + } + return descriptor?.enumerable === true && ObjectHasOwn(descriptor, "value") + ? descriptor + : undefined; +} + +/** Project effective request settings without persisting raw provider options. */ +export function buildModelCallContextRequest( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): ModelCallRequest | undefined { + return buildModelCallRequest(options, resolvePersistedReasoning(model, options)); +} + +function buildModelCallRequest( + options: ModelCallRequestSource, + reasoning = options.reasoning, +): ModelCallRequest | undefined { + const projectedReasoning = reasoning + ? { + ...(reasoning.enabled !== undefined ? { enabled: reasoning.enabled } : {}), + ...(reasoning.effort !== undefined ? { effort: reasoning.effort } : {}), + ...(reasoning.budgetTokens !== undefined ? { budgetTokens: reasoning.budgetTokens } : {}), + } + : undefined; + const request: ModelCallRequest = { + ...(options.maxOutputTokens !== undefined ? { maxOutputTokens: options.maxOutputTokens } : {}), + ...(options.temperature !== undefined ? { temperature: options.temperature } : {}), + ...(options.topP !== undefined ? { topP: options.topP } : {}), + ...(options.topK !== undefined ? { topK: options.topK } : {}), + ...(options.stopSequences !== undefined ? { stopSequences: [...options.stopSequences] } : {}), + ...(options.seed !== undefined ? { seed: options.seed } : {}), + ...(options.presencePenalty !== undefined ? { presencePenalty: options.presencePenalty } : {}), + ...(options.frequencyPenalty !== undefined + ? { frequencyPenalty: options.frequencyPenalty } + : {}), + ...(projectedReasoning && Object.keys(projectedReasoning).length > 0 + ? { reasoning: projectedReasoning } + : {}), + }; + return Object.keys(request).length > 0 ? request : undefined; +} + +/** Resolve the canonical provider recorded by the existing durable contract. */ +export function resolveModelCallProvider(model: ModelCallRuntimeMetadata): string | undefined { + if (typeof model.modelProvider === "string" && model.modelProvider !== "") { + return model.modelProvider; + } + return model.provider === "veryfront-cloud" ? undefined : model.provider; +} + +function resolvePersistedReasoning( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): RuntimeReasoningOption | undefined { + const modelProvider = resolveModelCallProvider(model); + if (modelProvider === "openai" && typeof model.modelId === "string") { + const reasoning = resolveOpenAIReasoningConfig(model.modelId, modelProvider, options.reasoning); + return reasoning ? { enabled: true, effort: reasoning.effort } : options.reasoning; + } + + // The Anthropic request builder only gives neutral reasoning precedence when + // it enables thinking; otherwise a raw provider thinking config remains effective. + if (modelProvider !== "anthropic" || options.reasoning?.enabled === true) { + return options.reasoning; + } + + const providerOptions = options.providerOptions; + if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { + return options.reasoning; + } + const anthropic = readOwnEnumerableDataDescriptor(providerOptions, "anthropic")?.value; + if (!anthropic || typeof anthropic !== "object" || Array.isArray(anthropic)) { + return options.reasoning; + } + const thinking = readOwnEnumerableDataDescriptor(anthropic, "thinking")?.value; + if (!thinking || typeof thinking !== "object" || Array.isArray(thinking)) { + return options.reasoning; + } + const thinkingType = readOwnEnumerableDataDescriptor(thinking, "type")?.value; + if (thinkingType === "disabled") { + return { enabled: false }; + } + if (thinkingType !== "adaptive" && thinkingType !== "enabled") { + return options.reasoning; + } + + if (thinkingType === "enabled") { + const budgetTokens = readOwnEnumerableDataDescriptor(thinking, "budget_tokens")?.value; + return { + enabled: true, + ...(typeof budgetTokens === "number" && Number.isInteger(budgetTokens) && budgetTokens >= 0 + ? { budgetTokens } + : {}), + }; + } + + const outputConfig = readOwnEnumerableDataDescriptor(anthropic, "output_config")?.value; + const effort = outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) + ? readOwnEnumerableDataDescriptor(outputConfig, "effort")?.value + : undefined; + return { + enabled: true, + ...(effort === "low" || effort === "medium" || effort === "high" || effort === "max" + ? { effort } + : {}), + }; +} diff --git a/src/runtime/runtime-bridge.ts b/src/runtime/runtime-bridge.ts index 445147683b..3c6057cc57 100644 --- a/src/runtime/runtime-bridge.ts +++ b/src/runtime/runtime-bridge.ts @@ -30,16 +30,18 @@ import { } from "#veryfront/provider/runtime-inspection.ts"; import { NOT_SUPPORTED } from "#veryfront/errors"; import type { RuntimeReasoningOption } from "#veryfront/agent/types.ts"; -import { resolveOpenAIReasoningConfig } from "#veryfront/provider/shared/openai-reasoning.ts"; import { DurableRunEventPersistenceError } from "#veryfront/agent/conversation/private-run-event.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; import type { AgentRunModelCallContextEvent, ModelCallMessage, - ModelCallRequest, ModelCallTool, } from "./model-call-context.ts"; import { getActiveRunEventSinks } from "./run-event-sink-context.ts"; +import { + buildModelCallContextRequest, + resolveModelCallProvider, +} from "./model-call-context-request.ts"; const cloneStructuredValue = globalThis.structuredClone; const ObjectDefineProperty = Object.defineProperty; @@ -711,110 +713,13 @@ function buildDirectModelOptions( }; } -function buildModelCallRequest( - options: ModelCallRequestSource, - reasoning = options.reasoning, -): ModelCallRequest | undefined { - const projectedReasoning = reasoning - ? { - ...(reasoning.enabled !== undefined ? { enabled: reasoning.enabled } : {}), - ...(reasoning.effort !== undefined ? { effort: reasoning.effort } : {}), - ...(reasoning.budgetTokens !== undefined ? { budgetTokens: reasoning.budgetTokens } : {}), - } - : undefined; - const request: ModelCallRequest = { - ...(options.maxOutputTokens !== undefined ? { maxOutputTokens: options.maxOutputTokens } : {}), - ...(options.temperature !== undefined ? { temperature: options.temperature } : {}), - ...(options.topP !== undefined ? { topP: options.topP } : {}), - ...(options.topK !== undefined ? { topK: options.topK } : {}), - ...(options.stopSequences !== undefined ? { stopSequences: [...options.stopSequences] } : {}), - ...(options.seed !== undefined ? { seed: options.seed } : {}), - ...(options.presencePenalty !== undefined ? { presencePenalty: options.presencePenalty } : {}), - ...(options.frequencyPenalty !== undefined - ? { frequencyPenalty: options.frequencyPenalty } - : {}), - ...(projectedReasoning && Object.keys(projectedReasoning).length > 0 - ? { reasoning: projectedReasoning } - : {}), - }; - return Object.keys(request).length > 0 ? request : undefined; -} - -function resolveModelProvider(model: ModelRuntime): string | undefined { - if (typeof model.modelProvider === "string" && model.modelProvider !== "") { - return model.modelProvider; - } - return model.provider === "veryfront-cloud" ? undefined : model.provider; -} - -function resolvePersistedReasoning( - model: ModelRuntime, - options: DirectModelOptions, -): RuntimeReasoningOption | undefined { - const modelProvider = resolveModelProvider(model); - if (modelProvider === "openai" && typeof model.modelId === "string") { - const reasoning = resolveOpenAIReasoningConfig(model.modelId, modelProvider, options.reasoning); - return reasoning ? { enabled: true, effort: reasoning.effort } : options.reasoning; - } - - // The Anthropic request builder only gives neutral reasoning precedence when - // it enables thinking; otherwise a raw provider thinking config remains effective. - if (modelProvider !== "anthropic" || options.reasoning?.enabled === true) { - return options.reasoning; - } - - const providerOptions = options.providerOptions; - if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { - return options.reasoning; - } - const anthropic = readOwnEnumerableDataDescriptor(providerOptions, "anthropic")?.value; - if (!anthropic || typeof anthropic !== "object" || Array.isArray(anthropic)) { - return options.reasoning; - } - const thinking = readOwnEnumerableDataDescriptor(anthropic, "thinking")?.value; - if (!thinking || typeof thinking !== "object" || Array.isArray(thinking)) { - return options.reasoning; - } - const thinkingType = readOwnEnumerableDataDescriptor(thinking, "type")?.value; - if (thinkingType === "disabled") { - return { enabled: false }; - } - if (thinkingType !== "adaptive" && thinkingType !== "enabled") { - return options.reasoning; - } - - if (thinkingType === "enabled") { - const budgetTokens = readOwnEnumerableDataDescriptor(thinking, "budget_tokens")?.value; - return { - enabled: true, - ...(typeof budgetTokens === "number" && Number.isInteger(budgetTokens) && budgetTokens >= 0 - ? { budgetTokens } - : {}), - }; - } - - const outputConfig = readOwnEnumerableDataDescriptor(anthropic, "output_config")?.value; - const effort = outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) - ? readOwnEnumerableDataDescriptor(outputConfig, "effort")?.value - : undefined; - return { - enabled: true, - ...(effort === "low" || effort === "medium" || effort === "high" || effort === "max" - ? { effort } - : {}), - }; -} - async function emitModelCallContextEvent( options: DirectTextOptions, directOptions: DirectModelOptions, ): Promise { const sinks = getActiveRunEventSinks(); if (!sinks.mandatory && !sinks.public) return; - const request = buildModelCallRequest( - directOptions, - resolvePersistedReasoning(options.model, directOptions), - ); + const request = buildModelCallContextRequest(options.model, directOptions); const event: AgentRunModelCallContextEvent = { type: "AGENT_RUN_MODEL_CALL_CONTEXT", @@ -822,8 +727,8 @@ async function emitModelCallContextEvent( ? { model: { id: options.model.modelId, - ...(resolveModelProvider(options.model) - ? { modelProvider: resolveModelProvider(options.model) } + ...(resolveModelCallProvider(options.model) + ? { modelProvider: resolveModelCallProvider(options.model) } : {}), }, } diff --git a/tests/integration/agent/executor-model-dispatch-contract.test.ts b/tests/integration/agent/executor-model-dispatch-contract.test.ts new file mode 100644 index 0000000000..bba8104d51 --- /dev/null +++ b/tests/integration/agent/executor-model-dispatch-contract.test.ts @@ -0,0 +1,225 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertNotEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { createWarningCollector } from "#veryfront/provider/shared/index.ts"; +import type { AgentRunModelCallContextEvent } from "#veryfront/runtime/model-call-context.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { createExecutorModelRuntimeResolver } from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { createHostedExecutorModelBroker } from "#veryfront/agent/hosted/executor-model-dispatch.ts"; +import { buildGoogleGenerateContentRequest } from "../../../extensions/ext-llm-google/src/google-request-builder.ts"; +import { buildAnthropicMessagesRequest } from "../../../extensions/ext-llm-anthropic/src/anthropic-request-builder.ts"; +import { buildOpenAIChatRequest } from "../../../extensions/ext-llm-openai/src/openai-chat-request-builder.ts"; +import { buildOpenAIResponsesRequest } from "../../../extensions/ext-llm-openai/src/openai-responses-request-builder.ts"; + +const options: ModelRuntimeCallOptions = { + prompt: [{ role: "system", content: "Captured system" }, { + role: "user", + content: [{ type: "text", text: "Captured prompt" }], + }], + tools: [{ type: "function", name: "lookup", inputSchema: { type: "object", properties: {} } }], + maxOutputTokens: 12, + temperature: 0.4, +}; + +type Builder = (options: ModelRuntimeCallOptions) => Record; + +async function connected(provider: string, build: Builder) { + const modelId = `veryfront-cloud/${provider}/synthetic`; + const allowedModelIds = new Set([modelId]); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const events: AgentRunModelCallContextEvent[] = []; + const bodies: Record[] = []; + const operations = createHostedExecutorModelBroker({ + allowedModelIds, + scope: { binding, signal: new AbortController().signal, assertActive() {} }, + runEventSink: (event) => { + events.push(event); + }, + resolveModelRuntime: () => ({ + provider: "veryfront-cloud", + modelProvider: provider, + modelId: "synthetic", + doGenerate(call: ModelRuntimeCallOptions) { + bodies.push(build(call)); + return Promise.resolve({}); + }, + doStream() { + throw new Error("Stream is not used by this offline builder fixture"); + }, + }), + }); + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + }); + const resolver = await createExecutorModelRuntimeResolver({ channel: caller, allowedModelIds }); + return { + runtime: resolver(modelId)!, + events, + bodies, + async close() { + caller.close(); + await broker.closed; + }, + }; +} + +describe("hosted executor model request contracts", () => { + it("rejects native fields that the first-party builders would merge over captured input", async () => { + const cases: { provider: string; build: Builder; overrides: Record }[] = [ + { + provider: "google", + build: (call) => ({ + ...buildGoogleGenerateContentRequest("veryfront-cloud", call, createWarningCollector()), + }), + overrides: { + contents: [{ role: "user", parts: [{ text: "Other prompt" }] }], + systemInstruction: { parts: [{ text: "Other system" }] }, + tools: [], + }, + }, + { + provider: "anthropic", + build: (call) => ({ + ...buildAnthropicMessagesRequest( + "claude-haiku-4-5", + "veryfront-cloud", + call, + false, + createWarningCollector(), + ), + }), + overrides: { + messages: [{ role: "user", content: [{ type: "text", text: "Other prompt" }] }], + system: "Other system", + tools: [], + max_tokens: 99, + temperature: 0.9, + }, + }, + { + provider: "openai", + build: (call) => ({ + ...buildOpenAIChatRequest( + "gpt-4o", + "veryfront-cloud", + call, + false, + createWarningCollector(), + ), + }), + overrides: { tools: [], max_completion_tokens: 99, temperature: 0.9 }, + }, + { + provider: "openai", + build: (call) => ({ + ...buildOpenAIResponsesRequest( + "gpt-4o", + "veryfront-cloud", + call, + false, + createWarningCollector(), + ), + }), + overrides: { tools: [], max_output_tokens: 99, temperature: 0.9 }, + }, + ]; + for (const testCase of cases) { + const channels = await connected(testCase.provider, testCase.build); + try { + await channels.runtime.doGenerate(options); + assertEquals(channels.events[0]?.messages, options.prompt); + assertEquals(channels.events[0]?.tools, options.tools); + assertEquals(channels.bodies, [testCase.build(options)]); + for (const bucket of [testCase.provider, "veryfront-cloud"]) { + for (const [field, value] of Object.entries(testCase.overrides)) { + const overridden = { ...options, providerOptions: { [bucket]: { [field]: value } } }; + assertNotEquals(testCase.build(overridden)[field], testCase.build(options)[field]); + await assertRejects( + async () => await channels.runtime.doGenerate(overridden), + Error, + "operation-failed", + ); + } + } + assertEquals(channels.events.length, 1); + assertEquals(channels.bodies.length, 1); + } finally { + await channels.close(); + } + } + }); + + it("preserves a native Google response schema with exact neutral control and reasoning values", async () => { + const responseSchema = { + type: "OBJECT", + properties: { + contents: { type: "STRING" }, + tools: { type: "STRING" }, + temperature: { type: "NUMBER" }, + }, + }; + const channels = await connected( + "google", + (call) => ({ + ...buildGoogleGenerateContentRequest("veryfront-cloud", call, createWarningCollector()), + }), + ); + try { + const reasonings = [ + { enabled: true, effort: "low" }, + { enabled: true, effort: "medium" }, + { enabled: true, effort: "high" }, + { enabled: true, effort: "max" }, + { enabled: true, budgetTokens: 4096 }, + ] as const; + for (const reasoning of reasonings) { + const baseline = buildGoogleGenerateContentRequest("veryfront-cloud", { + ...options, + reasoning, + }, createWarningCollector()); + const generationConfig = { + ...baseline.generationConfig, + responseMimeType: "application/json", + responseSchema, + }; + await channels.runtime.doGenerate({ + ...options, + reasoning, + providerOptions: { google: { generationConfig } }, + }); + assertEquals(channels.bodies.at(-1)?.generationConfig, generationConfig); + assertEquals(channels.events.at(-1)?.request, { + maxOutputTokens: 12, + temperature: 0.4, + reasoning, + }); + } + await assertRejects( + async () => + await channels.runtime.doGenerate({ + ...options, + providerOptions: { google: { generationConfig: { responseSchema } } }, + }), + Error, + "operation-failed", + ); + assertEquals(channels.events.length, reasonings.length); + assertEquals(channels.bodies.length, reasonings.length); + } finally { + await channels.close(); + } + }); +}); From 01bc964dec9a4ba43652ec59d1962271934b2138 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 18:15:51 +0200 Subject: [PATCH 007/194] test(agent): initialize executor schemas on Node and Bun --- src/agent/hosted/executor-agent-bridge.test.ts | 1 + src/agent/hosted/executor-agent-errors.test.ts | 1 + src/agent/streaming/executor-data-producers.test.ts | 1 + src/agent/streaming/executor-data-stream.test.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/src/agent/hosted/executor-agent-bridge.test.ts b/src/agent/hosted/executor-agent-bridge.test.ts index 5dc7bc5580..cf7c719880 100644 --- a/src/agent/hosted/executor-agent-bridge.test.ts +++ b/src/agent/hosted/executor-agent-bridge.test.ts @@ -1,3 +1,4 @@ +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 type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; diff --git a/src/agent/hosted/executor-agent-errors.test.ts b/src/agent/hosted/executor-agent-errors.test.ts index aeb5c11ab2..7d3ea041de 100644 --- a/src/agent/hosted/executor-agent-errors.test.ts +++ b/src/agent/hosted/executor-agent-errors.test.ts @@ -1,3 +1,4 @@ +import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { VeryfrontError } from "#veryfront/errors"; diff --git a/src/agent/streaming/executor-data-producers.test.ts b/src/agent/streaming/executor-data-producers.test.ts index 02ac2ecef2..fa682179b7 100644 --- a/src/agent/streaming/executor-data-producers.test.ts +++ b/src/agent/streaming/executor-data-producers.test.ts @@ -1,3 +1,4 @@ +import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createMockResult } from "../runtime/chat-stream-handler.test-helpers.ts"; diff --git a/src/agent/streaming/executor-data-stream.test.ts b/src/agent/streaming/executor-data-stream.test.ts index 7fad877d9b..1bc8acd165 100644 --- a/src/agent/streaming/executor-data-stream.test.ts +++ b/src/agent/streaming/executor-data-stream.test.ts @@ -1,3 +1,4 @@ +import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; From b3745bafdd504222f35bf2b369bf997954118979 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 19:24:00 +0200 Subject: [PATCH 008/194] fix(agent): preserve curated model failures across executor channels --- src/agent/hosted/executor-model-bridge.ts | 62 ++- .../hosted/executor-model-errors.test.ts | 425 ++++++++++++++++++ src/agent/hosted/executor-model-errors.ts | 41 ++ src/agent/hosted/executor-model-schema.ts | 2 + src/chat/provider-error-registry.ts | 110 +++++ src/chat/provider-errors.ts | 53 +-- ...xecutor-model-error-classification.test.ts | 92 ++++ 7 files changed, 733 insertions(+), 52 deletions(-) create mode 100644 src/agent/hosted/executor-model-errors.test.ts create mode 100644 src/agent/hosted/executor-model-errors.ts create mode 100644 src/chat/provider-error-registry.ts create mode 100644 tests/integration/agent/executor-model-error-classification.test.ts diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 0acde235db..eefcc79313 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -1,5 +1,6 @@ import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { executorModelFailure, throwExecutorModelFailure } from "./executor-model-errors.ts"; import type { ExecutorChannel, ExecutorOperation, @@ -72,7 +73,11 @@ export function createExecutorModelBroker(options: { async handle(input, context) { const { modelId } = parseExecutorModelData(getExecutorModelRequestSchema(), input); context.signal.throwIfAborted(); - await getModel(modelId).prepare?.(context.signal); + try { + await getModel(modelId).prepare?.(context.signal); + } catch (error) { + return modelFailureOrThrow(error, context); + } return null; }, }], @@ -102,7 +107,12 @@ export function createExecutorModelBroker(options: { mode: "unary", async handle(input, context) { const call = parseCall(input, context); - const result = await call.model.doGenerate(call.options); + let result; + try { + result = await call.model.doGenerate(call.options); + } catch (error) { + return modelFailureOrThrow(error, context); + } // Only the neutral result fields leave the broker. Native request and // response objects, headers, and transport diagnostics are never copied. const data = executorModelJson({ @@ -121,7 +131,13 @@ export function createExecutorModelBroker(options: { mode: "stream", async *handle(input, context) { const call = parseCall(input, context); - const result = await call.model.doStream(call.options); + let result; + try { + result = await call.model.doStream(call.options); + } catch (error) { + yield modelFailureOrThrow(error, context); + return; + } const reader = result.stream.getReader(); // Later reader.cancel() calls do not wait for the first call's provider cleanup. let cancellation: Promise | undefined; @@ -153,10 +169,12 @@ export function createExecutorModelBroker(options: { complete = true; return; } + throwProviderStreamError(next.value); const value = executorModelJson(next.value); - rejectStreamError(value); yield { type: "chunk", value }; } + } catch (error) { + yield modelFailureOrThrow(error, context); } finally { context.signal.removeEventListener("abort", cancel); if (!complete) await cancel().catch(() => {}); @@ -183,16 +201,34 @@ function modelMetadata(id: string, model: ModelRuntime) { }; } -function rejectStreamError(value: JsonValue, rawEnvelope = false): void { +function modelFailureOrThrow(error: unknown, context: ExecutorOperationContext) { + context.signal.throwIfAborted(); + const failure = executorModelFailure(error); + if (failure) return failure; + throw new TypeError("Managed model operation failed"); +} + +function throwProviderStreamError(value: unknown, rawEnvelope = false): void { + if (value === null || typeof value !== "object" || Array.isArray(value)) return; + const type = Object.getOwnPropertyDescriptor(value, "type")?.value; + // Normalized provider-tool failures are recoverable results, never transport failures. + if (type === "tool-error" && !rawEnvelope) return; + const error = Object.getOwnPropertyDescriptor(value, "error"); + if (type === "error" || error) throw error && "value" in error ? error.value : value; + const rawValue = Object.getOwnPropertyDescriptor(value, "rawValue")?.value; + if (type === "raw" && rawValue !== undefined) throwProviderStreamError(rawValue, true); +} + +/** Received chunks cannot classify failures or expose peer-supplied diagnostics. */ +function rejectReceivedStreamError(value: JsonValue, rawEnvelope = false): void { if (value === null || typeof value !== "object" || Array.isArray(value)) return; - // First-party provider-tool failures are normalized results; inference can - // continue with text and final usage. Raw provider errors remain fatal. if (value.type === "tool-error" && !rawEnvelope) return; if (value.type === "error" || Object.hasOwn(value, "error")) { - throw new TypeError("Managed model stream failed"); + throw new TypeError("Invalid managed model stream chunk"); + } + if (value.type === "raw" && value.rawValue !== undefined) { + rejectReceivedStreamError(value.rawValue, true); } - // Raw stream support still must not expose a provider's error envelope. - if (value.type === "raw" && value.rawValue !== undefined) rejectStreamError(value.rawValue, true); } /** @@ -296,12 +332,14 @@ function createExecutorModelRuntime( const result = await channel.request("model.prepare", { modelId: id }, { signal: combinedSignal, }); + throwExecutorModelFailure(result); if (result !== null) throw new TypeError("Invalid managed model preparation result"); }, async doGenerate(options: ModelRuntimeCallOptions) { const call = makeCall(options); assertActive(); const result = await channel.request("model.generate", call.input, { signal: call.signal }); + throwExecutorModelFailure(result); return parseExecutorModelData(getExecutorModelGenerateResultSchema(), result); }, async doStream(options: ModelRuntimeCallOptions) { @@ -312,6 +350,7 @@ function createExecutorModelRuntime( const first = await iterator.next(); if (first.done) throw new TypeError("Managed model stream start is missing"); const start = parseExecutorModelData(getExecutorModelStreamFrameSchema(), first.value); + throwExecutorModelFailure(start); if (start.type !== "start") throw new TypeError("Invalid managed model stream start"); const stream = new ReadableStream({ async pull(controller) { @@ -322,8 +361,9 @@ function createExecutorModelRuntime( return; } const frame = parseExecutorModelData(getExecutorModelStreamFrameSchema(), next.value); + throwExecutorModelFailure(frame); if (frame.type !== "chunk") throw new TypeError("Invalid managed model stream chunk"); - rejectStreamError(frame.value); + rejectReceivedStreamError(frame.value); controller.enqueue(frame.value); } catch (error) { controller.error(error); diff --git a/src/agent/hosted/executor-model-errors.test.ts b/src/agent/hosted/executor-model-errors.test.ts new file mode 100644 index 0000000000..eb37270822 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.test.ts @@ -0,0 +1,425 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { defineError, snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { + ProviderOverloadedError, + ProviderQuotaError, +} from "#veryfront/provider/runtime-loader/provider-http.ts"; +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { resolveRuntimeStreamErrorEvent } from "../runtime/chat-stream-handler.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "./executor-model-bridge.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const input = { prompt: [] }; +const privateDetail = "Synthetic private upstream detail"; + +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: privateDetail, + retryable: true, + retryAfterMs: 9000, + }); +} +function model( + overrides: Partial> = {}, +): ModelRuntime { + return { + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.resolve({}), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(c) { + c.close(); + }, + }), + }), + ...overrides, + }; +} + +async function connected( + runtime: ModelRuntime, + operations?: ReadonlyMap, +) { + const frames: string[] = []; + const transport = () => + new TransformStream({ + transform(value, controller) { + frames.push(new TextDecoder().decode(value)); + controller.enqueue(value); + }, + }); + const forward = transport(); + const backward = transport(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const receiver = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: operations ?? + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => runtime }), + }); + const resolver = operations + ? undefined + : await createExecutorModelRuntimeResolver({ channel: caller, allowedModelIds }); + return { + caller, + proxy: resolver?.(modelId), + frames, + async close() { + caller.close(); + await receiver.closed; + }, + }; +} + +function assertClassified(error: unknown, code: string, status: number) { + assertEquals(parseProviderError(error).code, code); + assertEquals(resolveRuntimeStreamErrorEvent(error).code, code); + assertEquals(snapshotVeryfrontError(error)?.slug, code.toLowerCase().replaceAll("_", "-")); + assertEquals(snapshotVeryfrontError(error)?.status, status); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(snapshotVeryfrontError(error)?.cause, undefined); +} + +describe("executor curated model failures", () => { + for (const phase of ["prepare", "generate", "stream setup"] as const) { + it(`preserves overload classification during ${phase}`, async () => { + const runtime = model({ + ...(phase === "prepare" ? { prepare: () => Promise.reject(overload()) } : {}), + ...(phase === "generate" ? { doGenerate: () => Promise.reject(overload()) } : {}), + ...(phase === "stream setup" ? { doStream: () => Promise.reject(overload()) } : {}), + }); + const channels = await connected(runtime); + try { + const error = await assertRejects(async () => { + if (phase === "prepare") await channels.proxy!.prepare!(); + else if (phase === "generate") await channels.proxy!.doGenerate(input); + else await channels.proxy!.doStream(input); + }); + assertClassified(error, "OVERLOADED_ERROR", 503); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("retryAfterMs"), false); + } finally { + await channels.close(); + } + }); + } + + it("preserves bounded credit, billing, context, and schema classifications", async () => { + const cases = [ + { + error: new Error( + 'Synthetic response {"slug":"insufficient-credits","error":"' + privateDetail + '"}', + ), + code: "INSUFFICIENT_CREDITS", + status: 402, + }, + { + error: { responseBody: '{"slug":"resource-limit-exceeded"}' }, + code: "RESOURCE_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("prompt is too long " + privateDetail), + code: "CONTEXT_LENGTH_EXCEEDED", + status: 413, + }, + { + error: new Error("invalid Veryfront schema " + privateDetail), + code: "PROJECT_SCHEMA_ERROR", + status: 400, + }, + { + error: new Error("response_format additionalProperties must be false " + privateDetail), + code: "OUTPUT_SCHEMA_NOT_CLOSED", + status: 400, + }, + { + error: new ProviderQuotaError({ + provider: "openai", + status: 429, + message: privateDetail, + retryable: false, + }), + code: "AI_PROVIDER_BILLING_ERROR", + status: 502, + }, + { + error: { type: "rate_limit_error", message: privateDetail }, + code: "RATE_LIMITED", + status: 429, + }, + { + error: new Error("assistant message prefill is unsupported " + privateDetail), + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + status: 400, + }, + { + error: { responseBody: '{"error":"AI provider spend limit reached"}' }, + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("workspace API usage limit has been reached " + privateDetail), + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + status: 502, + }, + ]; + for (const sample of cases) { + const channels = await connected(model({ doGenerate: () => Promise.reject(sample.error) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, sample.code, sample.status); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + } + }); + + for (const fatal of ["throw", "error part", "raw envelope"] as const) { + it(`preserves midstream ${fatal} classification and cleans up upstream`, async () => { + let cancelled = false; + let index = 0; + const channels = await connected( + model({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (index++ === 0) { + controller.enqueue({ type: "text-delta", delta: "Synthetic prefix" }); + } else if (fatal === "throw") { + controller.error(overload()); + } else {controller.enqueue( + fatal === "error part" ? { type: "error", error: overload() } : { + type: "raw", + rawValue: { + type: "error", + error: { type: "overloaded_error", message: privateDetail }, + }, + }, + );} + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }), + }), + ); + try { + const { stream } = await channels.proxy!.doStream(input); + const reader = stream.getReader(); + assertEquals((await reader.read()).value, { + type: "text-delta", + delta: "Synthetic prefix", + }); + const error = await assertRejects(() => reader.read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + await reader.cancel().catch(() => {}); + if (fatal !== "throw") assertEquals(cancelled, true); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + }); + } + + it("uses fixed diagnostics for curated registry slugs and ignores unknown registered codes", async () => { + const known = defineError({ + slug: "insufficient-credits", + category: "AGENT", + status: 599, + title: privateDetail, + }).create({ cause: privateDetail }); + const channels = await connected(model({ doGenerate: () => Promise.reject(known) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, "INSUFFICIENT_CREDITS", 402); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("599"), false); + } finally { + await channels.close(); + } + const unknown = defineError({ + slug: "synthetic-unknown-failure", + category: "AGENT", + status: 429, + title: privateDetail, + }).create(); + const other = await connected(model({ doGenerate: () => Promise.reject(unknown) })); + try { + const error = await assertRejects(async () => await other.proxy!.doGenerate(input)); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(other.frames.join("").includes(privateDetail), false); + } finally { + await other.close(); + } + }); + + it("rejects unknown or extended failure envelopes instead of trusting wire diagnostics", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.prepare", { + mode: "unary", + handle: () => ({ type: "failure", code: "OVERLOADED_ERROR", message: privateDetail }), + }); + operations.set("model.generate", { + mode: "unary", + handle: () => ({ type: "failure", code: "SYNTHETIC_UNKNOWN", status: 403 }), + }); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "failure", code: "OVERLOADED_ERROR", status: 599 }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const proxy = resolver(modelId)!; + for ( + const invoke of [ + () => proxy.prepare!(), + () => proxy.doGenerate(input), + () => proxy.doStream(input), + ] + ) { + const error = await assertRejects(async () => await invoke()); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + } + } finally { + await channels.close(); + } + }); + + for ( + const value of [ + { type: "error", error: privateDetail }, + { type: "error", error: { type: "overloaded_error", message: privateDetail } }, + { type: "raw", rawValue: { error: privateDetail } }, + { + type: "raw", + rawValue: { + type: "raw", + rawValue: { error: { type: "overloaded_error", message: privateDetail } }, + }, + }, + ] as JsonValue[] + ) { + it(`keeps received ${JSON.stringify(value).includes("overloaded_error") ? "structured" : "text"} ${JSON.stringify(value).includes("rawValue") ? "raw" : "error"} chunks opaque`, async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "chunk", value }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read(), TypeError); + assert(error instanceof TypeError); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + } finally { + await channels.close(); + } + }); + } + + it("classifies a received strict failure frame without trusting diagnostic chunks", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "failure", code: "OVERLOADED_ERROR" }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + } finally { + await channels.close(); + } + }); + + it("keeps unknown model and arbitrary channel errors opaque", async () => { + const channels = await connected( + model({ doGenerate: () => Promise.reject(new Error(privateDetail)) }), + ); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + const other = await connected( + model(), + new Map([["custom", { + mode: "unary", + handle() { + throw overload(); + }, + }]]), + ); + try { + const error = await assertRejects(() => other.caller.request("custom", {})); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await other.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-model-errors.ts b/src/agent/hosted/executor-model-errors.ts new file mode 100644 index 0000000000..897469f1c4 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.ts @@ -0,0 +1,41 @@ +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import { + CURATED_PROVIDER_FAILURE_CODES, + curatedProviderFailure, + type CuratedProviderFailureCode, +} from "#veryfront/chat/provider-error-registry.ts"; +import { defineError, type VeryfrontError } from "#veryfront/errors/types.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; + +export const getExecutorModelFailureSchema = defineSchema((v) => + v.object({ + type: v.literal("failure"), + code: v.enum(CURATED_PROVIDER_FAILURE_CODES), + }).strict() +); + +/** No message, body, cause, provider status, or response headers enter the envelope. */ +export function executorModelFailure( + error: unknown, +): { type: "failure"; code: CuratedProviderFailureCode } | undefined { + const code = parseProviderError(error).code; + const allowed = CURATED_PROVIDER_FAILURE_CODES.find((value) => value === code); + return allowed ? { type: "failure", code: allowed } : undefined; +} + +/** Reconstruct the existing registered-error shape using fixed local diagnostics. */ +export function createExecutorModelFailure(code: CuratedProviderFailureCode): VeryfrontError { + const failure = curatedProviderFailure(code); + return defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + status: failure.status, + title: failure.message, + }).create(); +} + +/** Only the exact bounded model failure envelope has authority to classify a reply. */ +export function throwExecutorModelFailure(value: unknown): void { + const result = getExecutorModelFailureSchema().safeParse(value); + if (result.success) throw createExecutorModelFailure(result.data.code); +} diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts index a1f09d4986..bc3c2b6dea 100644 --- a/src/agent/hosted/executor-model-schema.ts +++ b/src/agent/hosted/executor-model-schema.ts @@ -1,6 +1,7 @@ import type { InferSchema, Schema } 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 { getExecutorModelFailureSchema } from "./executor-model-errors.ts"; const MAX_MODELS = 128; const MAX_ITEMS = 1000; @@ -240,6 +241,7 @@ export const getExecutorModelGenerateResultSchema = defineSchema((v) => export const getExecutorModelStreamFrameSchema = defineSchema((v) => v.discriminatedUnion("type", [ + getExecutorModelFailureSchema(), v.object({ type: v.literal("start"), warnings: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), diff --git a/src/chat/provider-error-registry.ts b/src/chat/provider-error-registry.ts new file mode 100644 index 0000000000..d8fb299061 --- /dev/null +++ b/src/chat/provider-error-registry.ts @@ -0,0 +1,110 @@ +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; + +export const PROJECT_SCHEMA_ERROR = { + code: "PROJECT_SCHEMA_ERROR", + message: + "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", +} as const; + +export const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + message: + "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", +} as const; + +export const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { + code: "OUTPUT_SCHEMA_NOT_CLOSED", + message: + "The provider rejected the output schema because an object in it allows additional properties. " + + "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + + "built with defineSchema(), or set the property directly on a raw JSON Schema.", +} as const; + +export const AI_PROVIDER_SPEND_LIMIT_ERROR = { + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + message: + "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", + status: 402, +} as const; + +export const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + message: + "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", + status: 502, +} as const; + +export const AI_PROVIDER_BILLING_ERROR = { + code: "AI_PROVIDER_BILLING_ERROR", + message: + "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", + status: 502, +} as const; + +/** Codes transported across model boundaries; diagnostics are reconstructed locally. */ +export const CURATED_PROVIDER_FAILURE_CODES = [ + "OVERLOADED_ERROR", + "CONTEXT_LENGTH_EXCEEDED", + "INSUFFICIENT_CREDITS", + "RESOURCE_LIMIT_EXCEEDED", + "RATE_LIMITED", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", +] as const; +export type CuratedProviderFailureCode = typeof CURATED_PROVIDER_FAILURE_CODES[number]; + +const failures = { + OVERLOADED_ERROR: { + code: "OVERLOADED_ERROR", + message: "The LLM provider is currently overloaded", + status: 503, + }, + CONTEXT_LENGTH_EXCEEDED: { + code: "CONTEXT_LENGTH_EXCEEDED", + message: "Conversation is too long", + status: 413, + }, + INSUFFICIENT_CREDITS: { + code: "INSUFFICIENT_CREDITS", + message: "Insufficient AI credits", + status: 402, + }, + RESOURCE_LIMIT_EXCEEDED: { + code: "RESOURCE_LIMIT_EXCEEDED", + message: "Resource limit exceeded", + status: 402, + }, + RATE_LIMITED: { + code: "RATE_LIMITED", + message: "Too many requests. Please wait a moment and try again.", + status: 429, + }, + PROJECT_SCHEMA_ERROR: { ...PROJECT_SCHEMA_ERROR, status: 400 }, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL: { + ...MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + status: 400, + }, + OUTPUT_SCHEMA_NOT_CLOSED: { ...OUTPUT_SCHEMA_NOT_CLOSED_ERROR, status: 400 }, + AI_PROVIDER_SPEND_LIMIT_EXCEEDED: AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED: AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + AI_PROVIDER_BILLING_ERROR: AI_PROVIDER_BILLING_ERROR, +} as const; + +/** Return fixed local diagnostics; provider payload/status values are never forwarded. */ +export function curatedProviderFailure(code: CuratedProviderFailureCode) { + return { ...failures[code] }; +} + +/** Recognize only registered curated slugs, ignoring arbitrary code/status properties. */ +export function registeredProviderFailure(error: unknown) { + const snapshot = snapshotVeryfrontError(error); + if (!snapshot) return undefined; + const code = CURATED_PROVIDER_FAILURE_CODES.find((value) => + value.toLowerCase().replaceAll("_", "-") === snapshot.slug + ); + return code ? curatedProviderFailure(code) : undefined; +} diff --git a/src/chat/provider-errors.ts b/src/chat/provider-errors.ts index 7a860625bb..cbc12bd752 100644 --- a/src/chat/provider-errors.ts +++ b/src/chat/provider-errors.ts @@ -3,6 +3,15 @@ import { ProviderOverloadedError, ProviderQuotaError, } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { + AI_PROVIDER_BILLING_ERROR, + AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + OUTPUT_SCHEMA_NOT_CLOSED_ERROR, + PROJECT_SCHEMA_ERROR, + registeredProviderFailure, +} from "./provider-error-registry.ts"; export { safeJsonParse }; export type { SafeJsonParseResult } from "#veryfront/utils/json.ts"; @@ -18,47 +27,6 @@ const DEFAULT_EXTERNAL_SERVICE_ERROR = { message: "LLM provider service error", } as const; -const PROJECT_SCHEMA_ERROR = { - code: "PROJECT_SCHEMA_ERROR", - message: - "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", -} as const; - -const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { - code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", - message: - "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", -} as const; - -const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { - code: "OUTPUT_SCHEMA_NOT_CLOSED", - message: - "The provider rejected the output schema because an object in it allows additional properties. " + - "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + - "built with defineSchema(), or set the property directly on a raw JSON Schema.", -} as const; - -const AI_PROVIDER_SPEND_LIMIT_ERROR = { - code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", - message: - "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", - status: 402, -} as const; - -const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { - code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", - message: - "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", - status: 502, -} as const; - -const AI_PROVIDER_BILLING_ERROR = { - code: "AI_PROVIDER_BILLING_ERROR", - message: - "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", - status: 502, -} as const; - const MAX_PROVIDER_ERROR_DEPTH = 64; const MAX_PROVIDER_ERROR_TEXT_CHARS = 256 * 1024; const MAX_EMBEDDED_JSON_CANDIDATES = 32; @@ -483,6 +451,9 @@ function parseProviderErrorInner( return DEFAULT_EXTERNAL_SERVICE_ERROR; } + const registered = registeredProviderFailure(error); + if (registered) return registered; + if (error instanceof ProviderQuotaError) { return AI_PROVIDER_BILLING_ERROR; } diff --git a/tests/integration/agent/executor-model-error-classification.test.ts b/tests/integration/agent/executor-model-error-classification.test.ts new file mode 100644 index 0000000000..8365e6d88a --- /dev/null +++ b/tests/integration/agent/executor-model-error-classification.test.ts @@ -0,0 +1,92 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { ProviderOverloadedError } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const binding = { allocationId: "allocation-test", generation: 1, invocationId: "invocation-test" }; +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: "Synthetic private detail", + retryable: true, + }); +} + +describe("executor model agent error classification", () => { + for (const mode of ["generate", "stream", "midstream"] as const) { + it(`preserves overload through the real agent ${mode} runtime`, async () => { + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.reject(overload()), + doStream() { + if (mode !== "midstream") return Promise.reject(overload()); + let count = 0; + return Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (count++ === 0) { + controller.enqueue({ type: "text-delta", text: "Synthetic prefix" }); + } else controller.error(overload()); + }, + }, { highWaterMark: 0 }), + }); + }, + }), + }), + }); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: caller, + allowedModelIds, + }); + const runtime = new AgentRuntime("synthetic-agent", { + model: modelId, + system: "Synthetic system", + maxSteps: 1, + }, { resolveModelRuntime: resolver }); + if (mode === "generate") { + const error = await assertRejects(() => runtime.generate("Synthetic prompt")); + assertEquals(parseProviderError(error).code, "OVERLOADED_ERROR"); + assertEquals(snapshotVeryfrontError(error)?.status, 503); + } else { + const stream = await runtime.stream([{ + id: "message-test", + role: "user", + parts: [{ type: "text", text: "Synthetic prompt" }], + timestamp: 1, + }]); + const output = await new Response(stream).text(); + assert(output.includes('"code":"OVERLOADED_ERROR"')); + assertEquals(output.includes("Synthetic private detail"), false); + } + } finally { + caller.close(); + await broker.closed; + } + }); + } +}); From bebd002f2b396b88b25e6082acfea6f35293b21e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 19:34:36 +0200 Subject: [PATCH 009/194] feat(agent): discover isolated agent metadata within bound source --- src/agent/hosted/executor-discovery-node.ts | 72 +++ src/agent/hosted/executor-discovery-schema.ts | 180 ++++++++ src/agent/hosted/executor-discovery.test.ts | 424 ++++++++++++++++++ src/agent/hosted/executor-discovery.ts | 299 ++++++++++++ .../agent/executor-discovery.test.ts | 266 +++++++++++ 5 files changed, 1241 insertions(+) create mode 100644 src/agent/hosted/executor-discovery-node.ts create mode 100644 src/agent/hosted/executor-discovery-schema.ts create mode 100644 src/agent/hosted/executor-discovery.test.ts create mode 100644 src/agent/hosted/executor-discovery.ts create mode 100644 tests/integration/agent/executor-discovery.test.ts diff --git a/src/agent/hosted/executor-discovery-node.ts b/src/agent/hosted/executor-discovery-node.ts new file mode 100644 index 0000000000..3373de3db7 --- /dev/null +++ b/src/agent/hosted/executor-discovery-node.ts @@ -0,0 +1,72 @@ +import { clearConfigCache, getConfig } from "#veryfront/config"; +import { clearRegistryScope } from "#veryfront/registry/project-scoped-registry-manager.ts"; +import { tryGetRegistryScopeId } from "#veryfront/cache/cache-key-builder.ts"; +import { clearTranspileCache } from "#veryfront/discovery/transpiler.ts"; +import { discoverProjectAgentRuntime } from "../project/agent-runtime.ts"; +import { nodeAdapter } from "#veryfront/platform/adapters/node.ts"; +import type { ExecutorDiscoveryBackend } from "./executor-discovery.ts"; +import { ExecutorDiscoveryError } from "./executor-discovery-schema.ts"; +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import type { Bundler } from "#veryfront/extensions/bundler/bundler.ts"; + +let activeOwner: object | undefined; + +/** Executor-only default backend. Its module is loaded on the first metadata operation. */ +export function createNodeExecutorDiscoveryBackend( + input: { projectDir: string; cacheKey: string }, +): ExecutorDiscoveryBackend { + const owner = {}; + let claimed = false; + return { + async load(signal) { + signal.throwIfAborted(); + // This backend owns a dedicated process's default registry namespace. + // It does not invent an application project to obtain a registry scope. + if (activeOwner || tryGetRegistryScopeId() !== null) { + throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_BUSY"); + } + activeOwner = owner; + claimed = true; + const config = await getConfig(input.projectDir, nodeAdapter, { cacheKey: input.cacheKey }); + signal.throwIfAborted(); + return discoverProjectAgentRuntime({ + projectDir: input.projectDir, + cacheKey: input.cacheKey, + adapter: nodeAdapter, + // Application storage configuration does not select the executor's + // source filesystem. Preserve discovery paths/policy in a local copy. + config: { ...config, fs: { type: "local" } }, + allowHostProjectCodeExecution: true, + }); + }, + async cleanup() { + if (!claimed) return; + claimed = false; + let failed = false; + const bundler = tryResolve("Bundler"); + try { + for ( + const clean of [ + () => clearRegistryScope("__default__"), + clearTranspileCache, + clearConfigCache, + ] + ) { + try { + clean(); + } catch { + failed = true; + } + } + try { + await bundler?.stop?.(); + } catch { + failed = true; + } + } finally { + if (activeOwner === owner) activeOwner = undefined; + } + if (failed) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLEANUP_FAILED"); + }, + }; +} diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts new file mode 100644 index 0000000000..f5225a97f1 --- /dev/null +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -0,0 +1,180 @@ +import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; +import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; +import { defineError, snapshotVeryfrontError, VeryfrontError } from "#veryfront/errors/types.ts"; +import { getRuntimeAgentMarkdownDefinitionSchema } from "../runtime/agent-definition.ts"; +import { executorAgentJson } from "./executor-agent-schema.ts"; +import { hasControlCharacters, isWellFormedUtf16 } from "#veryfront/skill/string-safety.ts"; + +export const EXECUTOR_DISCOVERY_MAX_AGENTS = 256; +export const EXECUTOR_DISCOVERY_MAX_DEFINITION_BYTES = 64 * 1024; +const statuses = { + EXECUTOR_DISCOVERY_INVALID_INPUT: 400, + EXECUTOR_DISCOVERY_BINDING_MISMATCH: 403, + EXECUTOR_DISCOVERY_INVALID_OUTPUT: 502, + EXECUTOR_DISCOVERY_FAILED: 500, + EXECUTOR_DISCOVERY_CLEANUP_FAILED: 500, + EXECUTOR_DISCOVERY_BUSY: 409, + EXECUTOR_DISCOVERY_NOT_READY: 409, + EXECUTOR_DISCOVERY_CLOSED: 410, + CONFIG_INVALID: 400, + AGENT_NOT_FOUND: 404, + ABORTED: 499, +} as const; +type FailureCode = keyof typeof statuses; + +/** @internal Fixed registry-backed errors contain no source paths or project diagnostics. */ +export class ExecutorDiscoveryError extends VeryfrontError { + constructor(readonly code: FailureCode) { + super( + code, + defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + title: code, + status: statuses[code], + }), + ); + } +} + +export function discoveryFailureCode(error: unknown): FailureCode { + if (error instanceof ExecutorDiscoveryError) return error.code; + const slug = snapshotVeryfrontError(error)?.slug; + if (slug === "config-invalid") return "CONFIG_INVALID"; + if (slug === "agent-not-found") return "AGENT_NOT_FOUND"; + return "EXECUTOR_DISCOVERY_FAILED"; +} + +export const getExecutorDiscoveryIdSchema = defineSchema((v) => + v.string().min(1).max(128).refine((id) => + id.trim() === id && !hasControlCharacters(id) && isWellFormedUtf16(id) + ) +); +export const getExecutorDiscoverySourceSchema = defineSchema((v) => + v.discriminatedUnion("type", [ + v.object({ type: v.literal("release"), releaseId: getExecutorDiscoveryIdSchema() }).strict(), + v.object({ + type: v.literal("environment"), + environmentName: getExecutorDiscoveryIdSchema(), + releaseId: getExecutorDiscoveryIdSchema(), + }).strict(), + ]) +); +export type ExecutorDiscoverySource = InferSchema< + ReturnType +>; +export const getExecutorDiscoveryAgentSourceSchema = defineSchema((v) => + v.enum(["auto", "code", "markdown"] as const) +); +export const getExecutorDiscoveryRequestSchema = defineSchema((v) => v.object({}).strict()); +export const getExecutorAgentDescribeRequestSchema = defineSchema((v) => + v.object({ agentId: getExecutorDiscoveryIdSchema() }).strict() +); + +export const getExecutorDiscoveryCandidatesSchema = defineSchema((v) => + v.object({ + codeAgentIds: v.array(getExecutorDiscoveryIdSchema()).max(EXECUTOR_DISCOVERY_MAX_AGENTS), + markdownAgentIds: v.array(getExecutorDiscoveryIdSchema()).max(EXECUTOR_DISCOVERY_MAX_AGENTS), + }).strict().refine((value) => { + const ids = [...value.codeAgentIds, ...value.markdownAgentIds]; + return ids.length <= EXECUTOR_DISCOVERY_MAX_AGENTS && new Set(ids).size === ids.length; + }) +); + +/** Preserve existing definition semantics while rejecting additional wire fields. */ +export const getExecutorAgentDefinitionSchema = defineSchema((v) => { + const ids = () => v.array(getExecutorDiscoveryIdSchema()).max(EXECUTOR_DISCOVERY_MAX_AGENTS); + return getRuntimeAgentMarkdownDefinitionSchema().extend({ + id: getExecutorDiscoveryIdSchema(), + tools: v.union([v.literal(true), ids()]).optional(), + skills: v.union([v.literal(true), v.literal(false), ids()]).optional(), + deniedTools: ids().optional(), + delegates: ids().optional(), + providerTools: ids().optional(), + thinking: v.object({ enabled: v.boolean(), budgetTokens: v.number().positive().optional() }) + .strict().optional(), + mcpServers: v.array( + v.object({ + kind: v.enum(["veryfront-api", "veryfront-studio"] as const), + id: getExecutorDiscoveryIdSchema().optional(), + toolPolicy: v.object({ + allow: ids().optional(), + deny: ids().optional(), + approval: v.literal("never").optional(), + }).strict().optional(), + }).strict(), + ).max(64).optional(), + }).strict().refine((value) => + new TextEncoder().encode(JSON.stringify(value)).byteLength <= + EXECUTOR_DISCOVERY_MAX_DEFINITION_BYTES + ); +}); + +export const getExecutorDiscoveryDescriptionSchema = defineSchema((v) => + v.object({ + source: getExecutorDiscoverySourceSchema(), + candidates: getExecutorDiscoveryCandidatesSchema(), + defaultAgentId: getExecutorDiscoveryIdSchema(), + definition: getExecutorAgentDefinitionSchema(), + errorCount: v.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }).strict().refine((value) => value.defaultAgentId === value.definition.id) +); + +export const getExecutorAgentDescriptionSchema = defineSchema((v) => + v.object({ + source: getExecutorDiscoverySourceSchema(), + definition: getExecutorAgentDefinitionSchema(), + }).strict() +); + +const getExecutorDiscoveryFailureSchema = defineSchema((v) => + v.object({ + ok: v.literal(false), + code: v.enum( + [ + "EXECUTOR_DISCOVERY_INVALID_INPUT", + "EXECUTOR_DISCOVERY_BINDING_MISMATCH", + "EXECUTOR_DISCOVERY_INVALID_OUTPUT", + "EXECUTOR_DISCOVERY_FAILED", + "EXECUTOR_DISCOVERY_CLEANUP_FAILED", + "EXECUTOR_DISCOVERY_BUSY", + "EXECUTOR_DISCOVERY_NOT_READY", + "EXECUTOR_DISCOVERY_CLOSED", + "CONFIG_INVALID", + "AGENT_NOT_FOUND", + "ABORTED", + ] as const, + ), + }).strict() +); + +export const getExecutorDiscoveryResultSchema = defineSchema((v) => + v.discriminatedUnion("ok", [ + v.object({ ok: v.literal(true), value: getExecutorDiscoveryDescriptionSchema() }).strict(), + getExecutorDiscoveryFailureSchema(), + ]) +); +export const getExecutorAgentDescribeResultSchema = defineSchema((v) => + v.discriminatedUnion("ok", [ + v.object({ ok: v.literal(true), value: getExecutorAgentDescriptionSchema() }).strict(), + getExecutorDiscoveryFailureSchema(), + ]) +); + +export function parseDiscoveryData(schema: Schema, value: unknown, output = false): T { + const result = schema.safeParse(value); + if (!result.success) { + throw new ExecutorDiscoveryError( + output ? "EXECUTOR_DISCOVERY_INVALID_OUTPUT" : "EXECUTOR_DISCOVERY_INVALID_INPUT", + ); + } + return result.data; +} + +export function discoverySuccess(value: unknown): JsonValue { + try { + return executorAgentJson({ ok: true, value }, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + } catch { + throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_OUTPUT"); + } +} diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts new file mode 100644 index 0000000000..16083cda38 --- /dev/null +++ b/src/agent/hosted/executor-discovery.test.ts @@ -0,0 +1,424 @@ +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 { CONFIG_INVALID } from "#veryfront/errors"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { agent } from "../factory.ts"; +import type { Agent } from "../types.ts"; +import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; +import { createRuntimeAgentFromMarkdownDefinition } from "../runtime/agent-markdown-adapter.ts"; +import { createExecutorDiscovery, type ExecutorDiscoveryBackend } from "./executor-discovery.ts"; +import { ExecutorDiscoveryError } from "./executor-discovery-schema.ts"; + +const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; +const source = { type: "release", releaseId: "synthetic-release" } as const; +const codeAgent = (id = "coder") => + agent({ id, system: "Synthetic instructions", model: "openai/synthetic" }); +const markdownAgent = () => + createRuntimeAgentFromMarkdownDefinition({ + id: "writer", + name: "Writer", + description: "", + instructions: "Synthetic markdown", + }); +function runtime(agents: Agent[] = [codeAgent()]): ProjectAgentRuntimeDiscovery { + return { + agents: new Map(agents.map((value) => [value.id, value])), + tools: new Map(), + 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" }, + }; +} +function fixture( + input: { + agents?: Agent[]; + agentSource?: "auto" | "code" | "markdown"; + defaultAgentId?: string; + backend?: ExecutorDiscoveryBackend; + } = {}, +) { + let loads = 0; + let cleanups = 0; + const state = runtime(input.agents); + const controller = new AbortController(); + const owner = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + agentSource: input.agentSource, + defaultAgentId: input.defaultAgentId, + signal: controller.signal, + backend: input.backend ?? { + load: () => { + loads++; + return Promise.resolve(state); + }, + cleanup: () => { + cleanups++; + return Promise.resolve(); + }, + }, + }); + return { + owner, + state, + controller, + get loads() { + return loads; + }, + get cleanups() { + return cleanups; + }, + }; +} +async function call( + owner: ReturnType, + name: string, + value: JsonValue = {}, + context = { binding, signal: new AbortController().signal, deadline: Date.now() + 10_000 }, +) { + const operation = owner.operations.get(name); + assert(operation?.mode === "unary"); + return await operation.handle(value, context); +} + +describe("executor discovery operations", () => { + it("is lazy, exposes only metadata operations, and retains the runtime locally", async () => { + const f = fixture(); + try { + assertEquals(f.loads, 0); + assertEquals([...f.owner.operations.keys()], ["discovery.describe", "agent.describe"]); + assertThrows(() => f.owner.getRuntime(), ExecutorDiscoveryError); + const result = await call(f.owner, "discovery.describe"); + assertEquals(result, { + ok: true, + value: { + source, + candidates: { codeAgentIds: ["coder"], markdownAgentIds: [] }, + defaultAgentId: "coder", + definition: { + id: "coder", + name: "coder", + description: "", + instructions: "Synthetic instructions", + model: "openai/synthetic", + }, + errorCount: 0, + }, + }); + assertEquals(f.owner.getRuntime(), f.state); + await call(f.owner, "agent.describe", { agentId: "coder" }); + assertEquals(f.loads, 1); + } finally { + await f.owner.close(); + } + assertEquals(f.cleanups, 1); + assertThrows(() => f.owner.getRuntime(), ExecutorDiscoveryError); + await f.owner.close(); + assertEquals(f.cleanups, 1); + }); + + for (const policy of ["auto", "code", "markdown"] as const) { + it(`preserves ${policy} default selection`, async () => { + const f = fixture({ agents: [codeAgent(), markdownAgent()], agentSource: policy }); + try { + const result = await call(f.owner, "discovery.describe"); + if (policy === "auto") assertEquals(result, { ok: false, code: "CONFIG_INVALID" }); + else { + assert( + result !== null && typeof result === "object" && !Array.isArray(result) && + result.ok === true, + ); + const value = result.value; + assert(value !== null && typeof value === "object" && !Array.isArray(value)); + assertEquals(value.defaultAgentId, policy === "code" ? "coder" : "writer"); + } + } finally { + await f.owner.close(); + } + }); + } + + it("honors an explicit default and reports missing code agents without guessing", async () => { + const f = fixture({ + agents: [codeAgent("first"), codeAgent("second")], + defaultAgentId: "second", + agentSource: "code", + }); + try { + const result = await call(f.owner, "discovery.describe"); + assert( + result !== null && typeof result === "object" && !Array.isArray(result) && + result.ok === true, + ); + assertEquals(await call(f.owner, "agent.describe", { agentId: "missing" }), { + ok: false, + code: "AGENT_NOT_FOUND", + }); + assertEquals(f.loads, 1); + } finally { + await f.owner.close(); + } + }); + + it("rejects wire paths, source replacement, and wrong bindings before discovery", async () => { + const f = fixture(); + try { + const invalidInputs: JsonValue[] = [{ projectDir: "/other" }, { source }, { + authToken: "synthetic", + }]; + for (const value of invalidInputs) { + assertEquals(await call(f.owner, "discovery.describe", value), { + ok: false, + code: "EXECUTOR_DISCOVERY_INVALID_INPUT", + }); + } + assertEquals( + await call(f.owner, "agent.describe", { agentId: "coder", projectDir: "/other" }), + { ok: false, code: "EXECUTOR_DISCOVERY_INVALID_INPUT" }, + ); + assertEquals( + await call(f.owner, "discovery.describe", {}, { + binding: { ...binding, generation: 2 }, + signal: new AbortController().signal, + deadline: Date.now() + 10_000, + }), + { ok: false, code: "EXECUTOR_DISCOVERY_BINDING_MISMATCH" }, + ); + assertEquals(f.loads, 0); + } finally { + await f.owner.close(); + } + assertEquals(f.cleanups, 0); + }); + + it("projects an executable system once and preserves structured metadata", async () => { + let evaluations = 0; + const f = fixture({ + agents: [agent({ + id: "coder", + system: () => { + evaluations++; + return [{ + role: "system", + content: "Synthetic system", + providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, + }]; + }, + tools: { allowed: true, denied: false }, + providerTools: ["web_search"], + mcpServers: [{ kind: "veryfront-api", toolPolicy: { allow: ["read_file"] } }], + })], + }); + try { + const [first, second] = await Promise.all([ + call(f.owner, "discovery.describe"), + call(f.owner, "agent.describe", { agentId: "coder" }), + ]); + assertEquals(evaluations, 1); + assert(JSON.stringify(first).includes('"deniedTools":["denied"]')); + assert(JSON.stringify(second).includes('"cacheControl":{"type":"ephemeral"}')); + } finally { + await f.owner.close(); + } + }); + + it("keeps custom MCP transports unsupported and never serializes their functions", async () => { + const f = fixture({ + agents: [ + agent({ + id: "coder", + system: "Synthetic", + mcpServers: [{ + kind: "http", + id: "custom", + transport: { type: "http", url: "https://example.test/mcp" }, + }], + }), + ], + }); + try { + assertEquals(await call(f.owner, "discovery.describe"), { + ok: false, + code: "CONFIG_INVALID", + }); + } finally { + await f.owner.close(); + } + }); + + it("returns only a count for publish-valid discovery errors", async () => { + const f = fixture(); + f.state.errors.push({ + file: "/synthetic-private-path", + error: new Error("synthetic-private-error"), + }); + try { + const text = JSON.stringify(await call(f.owner, "discovery.describe")); + assert(text.includes('"errorCount":1')); + assertEquals(text.includes("synthetic-private"), false); + } finally { + await f.owner.close(); + } + }); + + it("joins canceled discovery before cleaning partial setup and cannot be resurrected", async () => { + const loaded = Promise.withResolvers(); + const entered = Promise.withResolvers(); + let cleaned = 0; + const f = fixture({ + backend: { + load: () => { + entered.resolve(); + return loaded.promise; + }, + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + }, + }); + const response = call(f.owner, "discovery.describe"); + await entered.promise; + f.controller.abort(); + assertThrows(() => f.owner.getRuntime(), ExecutorDiscoveryError); + assertEquals(cleaned, 0); + loaded.resolve(runtime()); + assertEquals(await response, { ok: false, code: "ABORTED" }); + await f.owner.settled; + assertEquals(cleaned, 1); + assertEquals(await call(f.owner, "agent.describe", { agentId: "coder" }), { + ok: false, + code: "EXECUTOR_DISCOVERY_CLOSED", + }); + }); + + it("cleans failed partial discovery once without echoing its error", async () => { + let cleaned = 0; + const f = fixture({ + backend: { + load: () => Promise.reject(new Error("synthetic-private-error")), + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + }, + }); + assertEquals(await call(f.owner, "discovery.describe"), { + ok: false, + code: "EXECUTOR_DISCOVERY_FAILED", + }); + await f.owner.close(); + assertEquals(cleaned, 1); + }); + + it("cleans partial discovery even when the loader throws a registered configuration error", async () => { + let cleaned = 0; + const f = fixture({ + backend: { + load: () => + Promise.reject(CONFIG_INVALID.create({ detail: "synthetic-private-diagnostic" })), + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + }, + }); + assertEquals(await call(f.owner, "discovery.describe"), { ok: false, code: "CONFIG_INVALID" }); + assertEquals(cleaned, 1); + assertEquals(f.owner.signal.aborted, true); + await f.owner.close(); + }); + + it("reports cleanup failure without exposing its diagnostic", async () => { + const f = fixture({ + backend: { + load: () => Promise.resolve(runtime()), + cleanup: () => Promise.reject(new Error("synthetic-private-diagnostic")), + }, + }); + await call(f.owner, "discovery.describe"); + const error = await assertRejects(() => f.owner.close(), ExecutorDiscoveryError); + assert(error instanceof ExecutorDiscoveryError); + assertEquals(error.code, "EXECUTOR_DISCOVERY_CLEANUP_FAILED"); + }); + + it("joins the same cleanup promise when close reenters through an abort listener", async () => { + const cleanup = Promise.withResolvers(); + let cleanups = 0; + let reentered: Promise | undefined; + let settled = false; + const f = fixture({ + backend: { + load: () => Promise.resolve(runtime()), + cleanup: () => { + cleanups++; + return cleanups === 1 ? cleanup.promise : Promise.resolve(); + }, + }, + }); + await call(f.owner, "discovery.describe"); + f.owner.signal.addEventListener("abort", () => { + reentered = f.owner.close(); + }, { once: true }); + void f.owner.settled.then(() => settled = true); + const closing = f.owner.close(); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(reentered, closing); + assertEquals(cleanups, 1); + assertEquals(settled, false); + } finally { + cleanup.resolve(); + await closing; + await reentered; + } + }); + + for (const agentSource of ["auto", "markdown"] as const) { + it(`keeps ${agentSource} discovery alive after a missing non-file ID`, async () => { + const f = fixture({ agents: [markdownAgent()], agentSource }); + try { + await call(f.owner, "discovery.describe"); + assertEquals(await call(f.owner, "agent.describe", { agentId: "missing:agent" }), { + ok: false, + code: "AGENT_NOT_FOUND", + }); + assertEquals(f.owner.signal.aborted, false); + const valid = await call(f.owner, "agent.describe", { agentId: "writer" }); + assert( + valid !== null && typeof valid === "object" && !Array.isArray(valid) && valid.ok === true, + ); + } finally { + await f.owner.close(); + } + }); + } + + it("rejects oversized definitions and candidate catalogs", async () => { + for ( + const agents of [ + [agent({ id: "coder", system: "x".repeat(65_537) })], + Array.from({ length: 257 }, (_, i) => codeAgent(`coder-${i}`)), + ] + ) { + const f = fixture({ agents, defaultAgentId: agents[0]?.id }); + try { + assertEquals(await call(f.owner, "discovery.describe"), { + ok: false, + code: "EXECUTOR_DISCOVERY_INVALID_OUTPUT", + }); + } finally { + await f.owner.close(); + } + } + }); +}); diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts new file mode 100644 index 0000000000..211a48869f --- /dev/null +++ b/src/agent/hosted/executor-discovery.ts @@ -0,0 +1,299 @@ +import { isAbsolute, join, relative, sep } from "node:path"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { + ProjectAgentRuntimeAgentSource, + ProjectAgentRuntimeDiscovery, +} from "../project/agent-runtime.ts"; +import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; +import { + discoveryFailureCode, + discoverySuccess, + EXECUTOR_DISCOVERY_MAX_AGENTS, + ExecutorDiscoveryError, + type ExecutorDiscoverySource, + getExecutorAgentDefinitionSchema, + getExecutorAgentDescribeRequestSchema, + getExecutorAgentDescriptionSchema, + getExecutorDiscoveryAgentSourceSchema, + getExecutorDiscoveryCandidatesSchema, + getExecutorDiscoveryDescriptionSchema, + getExecutorDiscoveryIdSchema, + getExecutorDiscoveryRequestSchema, + getExecutorDiscoverySourceSchema, + parseDiscoveryData, +} from "./executor-discovery-schema.ts"; + +export interface ExecutorDiscoveryBackend { + load(signal: AbortSignal): Promise; + /** Own partial setup even when load throws before returning a runtime. */ + cleanup(runtime: ProjectAgentRuntimeDiscovery | undefined): Promise; +} + +export interface ExecutorDiscoveryOptions { + binding: ExecutorBinding; + source: ExecutorDiscoverySource; + projectDir: string; + agentSource?: ProjectAgentRuntimeAgentSource; + defaultAgentId?: string; + signal: AbortSignal; + /** Local dependency injection; never populated from the protocol. */ + backend?: ExecutorDiscoveryBackend; +} + +export interface ExecutorDiscovery { + readonly operations: ReadonlyMap; + readonly signal: AbortSignal; + /** Local-only access for the next runtime.prepare stage. */ + getRuntime(): ProjectAgentRuntimeDiscovery; + /** Resolves after all discovery/projection and partial-resource cleanup settle. */ + readonly settled: Promise; + close(): Promise; +} + +/** + * One allocation's executor-local metadata owner. Construction does not load + * project configuration. Cleanup is not a process-reuse guarantee: the owning + * session must destroy the executor after this allocation. + */ +export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): ExecutorDiscovery { + const binding = Object.freeze(parseDiscoveryData(getExecutorBindingSchema(), input.binding)); + const source = Object.freeze( + parseDiscoveryData(getExecutorDiscoverySourceSchema(), input.source), + ); + const agentSource = parseDiscoveryData( + getExecutorDiscoveryAgentSourceSchema(), + input.agentSource ?? "auto", + ); + const defaultId = input.defaultAgentId === undefined + ? undefined + : parseDiscoveryData(getExecutorDiscoveryIdSchema(), input.defaultAgentId); + if ( + typeof input.projectDir !== "string" || !isAbsolute(input.projectDir) || + !(input.signal instanceof AbortSignal) + ) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_INPUT"); + const projectDir = input.projectDir; + const lifetime = new AbortController(); + const settled = Promise.withResolvers(); + void settled.promise.catch(() => {}); + let tail: Promise = Promise.resolve(); + let backend = input.backend; + let loadStarted = false; + let discovered = false; + let setupFailed = false; + let runtime: ProjectAgentRuntimeDiscovery | undefined; + let closing: Promise | undefined; + const definitions = new Map(); + const helpers = () => import("../project/agent-runtime.ts"); + + function assertActive() { + if (lifetime.signal.aborted) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLOSED"); + } + function close(): Promise { + if (closing) return closing; + // Memoize before synchronous abort listeners can reenter close(). + closing = tail.then(async () => { + try { + if (loadStarted) await backend?.cleanup(runtime); + } catch { + throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLEANUP_FAILED"); + } finally { + runtime = undefined; + definitions.clear(); + } + }); + void closing.then(settled.resolve, settled.reject); + input.signal.removeEventListener("abort", onAbort); + lifetime.abort(); + return closing; + } + const onAbort = () => { + void close().catch(() => {}); + }; + input.signal.addEventListener("abort", onAbort, { once: true }); + if (input.signal.aborted) onAbort(); + + async function discover() { + assertActive(); + if (runtime && discovered) return runtime; + if (!backend) { + const { createNodeExecutorDiscoveryBackend } = await import("./executor-discovery-node.ts"); + assertActive(); + backend = createNodeExecutorDiscoveryBackend({ + projectDir, + cacheKey: JSON.stringify(binding), + }); + } + loadStarted = true; + try { + runtime = await backend.load(lifetime.signal); + assertActive(); + // Validate the whole catalog before publishing local or wire access. + const module = await helpers(); + parseDiscoveryData( + getExecutorDiscoveryCandidatesSchema(), + module.getProjectAgentRuntimeAgentIdCandidates(runtime), + true, + ); + discovered = true; + } catch (error) { + setupFailed = true; + throw error; + } + return runtime; + } + + async function describeAgent(discovery: ProjectAgentRuntimeDiscovery, agentId: string) { + const cached = definitions.get(agentId); + if (cached) return cached; + const module = await helpers(); + const found = discovery.agents.get(agentId); + let definition: RuntimeAgentMarkdownDefinition; + if (found && module.doesProjectAgentRuntimeAgentMatchSource(found, agentSource)) { + definition = await module.runWithProjectAgentRuntime( + discovery, + () => module.createRuntimeAgentDefinitionFromAgent(found), + ); + } else { + if (agentSource === "code") throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); + const files = await import("../runtime/agent-definition-files.ts"); + const lookup = { baseDir: projectDir, id: agentId }; + if (!files.resolveRuntimeAgentDefinitionsDirInputSchema.safeParse(lookup).success) { + throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); + } + try { + // The standalone service helper searches ancestors for source-layout + // compatibility. An executor must attribute only its bound source. + const { realpath, readFile } = await import("node:fs/promises"); + const root = await realpath(projectDir); + const file = await realpath(join(root, "agents", `${agentId}.md`)); + const localPath = relative(root, file); + if (localPath === ".." || localPath.startsWith(`..${sep}`) || isAbsolute(localPath)) { + throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); + } + const { parseRuntimeAgentMarkdownDefinition } = await import( + "../runtime/agent-definition.ts" + ); + definition = parseRuntimeAgentMarkdownDefinition({ + id: agentId, + content: await readFile(file, "utf8"), + }); + } catch (error) { + if ( + error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT" + ) throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); + throw error; + } + } + assertActive(); + const parsed = parseDiscoveryData(getExecutorAgentDefinitionSchema(), definition, true); + if (parsed.id !== agentId || definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { + throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_OUTPUT"); + } + definitions.set(agentId, parsed); + return parsed; + } + + async function execute( + context: ExecutorOperationContext, + operation: () => Promise, + ): Promise { + if ( + context.binding.allocationId !== binding.allocationId || + context.binding.generation !== binding.generation || + context.binding.invocationId !== binding.invocationId + ) return { ok: false, code: "EXECUTOR_DISCOVERY_BINDING_MISMATCH" }; + if (lifetime.signal.aborted) return { ok: false, code: "EXECUTOR_DISCOVERY_CLOSED" }; + const onCancel = () => { + void close().catch(() => {}); + }; + context.signal.addEventListener("abort", onCancel, { once: true }); + const work = tail.then(async () => { + if (context.signal.aborted || Date.now() >= context.deadline) { + onCancel(); + throw new ExecutorDiscoveryError("ABORTED"); + } + assertActive(); + const value = await operation(); + assertActive(); + if (context.signal.aborted || Date.now() >= context.deadline) { + onCancel(); + throw new ExecutorDiscoveryError("ABORTED"); + } + return value; + }); + tail = work.then(() => {}, () => {}); + if (context.signal.aborted) onCancel(); + try { + return await work; + } catch (error) { + const code = lifetime.signal.aborted ? "ABORTED" : discoveryFailureCode(error); + if ( + setupFailed || code === "EXECUTOR_DISCOVERY_FAILED" || + code === "EXECUTOR_DISCOVERY_INVALID_OUTPUT" || + code === "ABORTED" + ) { + try { + await close(); + } catch { + return { ok: false, code: "EXECUTOR_DISCOVERY_CLEANUP_FAILED" }; + } + } + return { ok: false, code }; + } finally { + context.signal.removeEventListener("abort", onCancel); + } + } + + const operations = new Map([ + ["discovery.describe", { + mode: "unary", + handle(value, context) { + return execute(context, async () => { + parseDiscoveryData(getExecutorDiscoveryRequestSchema(), value); + const discovery = await discover(); + const module = await helpers(); + const candidates = module.getProjectAgentRuntimeAgentIdCandidates(discovery); + const defaultAgentId = defaultId ?? + module.resolveSingleProjectAgentRuntimeAgentId({ candidates, source: agentSource }); + if (!defaultAgentId) throw new ExecutorDiscoveryError("CONFIG_INVALID"); + const definition = await describeAgent(discovery, defaultAgentId); + return discoverySuccess( + parseDiscoveryData(getExecutorDiscoveryDescriptionSchema(), { + source, + candidates, + defaultAgentId, + definition, + errorCount: discovery.errors.length, + }, true), + ); + }); + }, + }], + ["agent.describe", { + mode: "unary", + handle(value, context) { + return execute(context, async () => { + const request = parseDiscoveryData(getExecutorAgentDescribeRequestSchema(), value); + const discovery = await discover(); + const definition = await describeAgent(discovery, request.agentId); + return discoverySuccess( + parseDiscoveryData(getExecutorAgentDescriptionSchema(), { source, definition }, true), + ); + }); + }, + }], + ]); + return { + operations, + signal: lifetime.signal, + settled: settled.promise, + close, + getRuntime() { + assertActive(); + if (!runtime || !discovered) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_NOT_READY"); + return runtime; + }, + }; +} diff --git a/tests/integration/agent/executor-discovery.test.ts b/tests/integration/agent/executor-discovery.test.ts new file mode 100644 index 0000000000..9e1d5e67ba --- /dev/null +++ b/tests/integration/agent/executor-discovery.test.ts @@ -0,0 +1,266 @@ +import "#veryfront/schemas/_test-setup.ts"; +import "#veryfront/skill/_test-setup.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { agentRegistry } from "#veryfront/agent/composition/index.ts"; +import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; +import { + ExecutorDiscoveryError, + getExecutorAgentDescribeResultSchema, + getExecutorDiscoveryResultSchema, +} from "#veryfront/agent/hosted/executor-discovery-schema.ts"; + +const source = { type: "release", releaseId: "synthetic-release" } as const; +const binding = { + allocationId: "filesystem-allocation", + invocationId: "filesystem-invocation", + generation: 1, +}; + +async function project() { + const dir = await mkdtemp(join(tmpdir(), "vf-executor-discovery-")); + const loaded = join(dir, "config-loaded"); + const projected = join(dir, "agent-projected"); + await mkdir(join(dir, "crew")); + await writeFile( + join(dir, "veryfront.config.ts"), + [ + 'import { appendFileSync } from "node:fs";', + `appendFileSync(${JSON.stringify(loaded)}, "loaded\\n");`, + 'export default { ai: { agents: { discovery: { paths: ["crew"] } } } };', + ].join("\n"), + ); + await writeFile( + join(dir, "crew", "writer.md"), + "---\nname: Writer\n---\n\nSynthetic markdown instructions.\n", + ); + await writeFile( + join(dir, "crew", "coder.ts"), + [ + 'import { appendFileSync } from "node:fs";', + 'export default { id: "coder", config: { id: "coder", name: "Coder", model: "auto",', + ` system() { appendFileSync(${ + JSON.stringify(projected) + }, "projected\\n"); return "Synthetic code instructions."; } },`, + ' async generate() { throw new Error("Inference is not used in discovery"); },', + ' async stream() { throw new Error("Inference is not used in discovery"); },', + ' async respond() { throw new Error("Inference is not used in discovery"); },', + ' getMemory() { throw new Error("Memory is not used in discovery"); },', + ' async getMemoryStats() { return { totalMessages: 0, estimatedTokens: 0, type: "test" }; },', + " async clearMemory() {},", + "};", + ].join("\n"), + ); + return { dir, loaded, projected, cleanup: () => rm(dir, { recursive: true, force: true }) }; +} + +function owner(dir: string, defaultAgentId?: string) { + return createExecutorDiscovery({ + binding, + source, + projectDir: dir, + defaultAgentId, + signal: new AbortController().signal, + }); +} +async function request(discovery: ReturnType, name: string, value: JsonValue = {}) { + const operation = discovery.operations.get(name); + assert(operation?.mode === "unary"); + return await operation.handle(value, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }); +} + +describe("isolated executor local project discovery", () => { + it("loads configured code and markdown only after an operation and retains executable state locally", async () => { + const p = await project(); + const discovery = owner(p.dir, "writer"); + try { + assertEquals(existsSync(p.loaded), false); + assertEquals(await request(discovery, "discovery.describe", { projectDir: p.dir }), { + ok: false, + code: "EXECUTOR_DISCOVERY_INVALID_INPUT", + }); + assertEquals(existsSync(p.loaded), false); + const summary = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(summary.ok); + assertEquals(summary.value.candidates, { + codeAgentIds: ["coder"], + markdownAgentIds: ["writer"], + }); + assertEquals(summary.value.defaultAgentId, "writer"); + assertEquals( + summary.value.definition.instructions.trim(), + "Synthetic markdown instructions.", + ); + assertEquals(existsSync(p.projected), false); + const code = getExecutorAgentDescribeResultSchema().parse( + await request(discovery, "agent.describe", { agentId: "coder" }), + ); + assert(code.ok); + assertEquals(code.value.definition.instructions, "Synthetic code instructions."); + await request(discovery, "agent.describe", { agentId: "coder" }); + assertEquals(await readFile(p.loaded, "utf8"), "loaded\n"); + assertEquals(await readFile(p.projected, "utf8"), "projected\n"); + assertEquals(typeof discovery.getRuntime().agents.get("coder")?.config.system, "function"); + assertEquals(JSON.stringify(summary).includes(p.dir), false); + } finally { + await discovery.close(); + await p.cleanup(); + } + assertThrows(() => discovery.getRuntime(), ExecutorDiscoveryError); + assertEquals(agentRegistry.get("coder"), undefined); + assertEquals(agentRegistry.get("writer"), undefined); + }); + + it("retains the existing ambiguity error for a real mixed-source project", async () => { + const p = await project(); + const discovery = owner(p.dir); + try { + assertEquals(await request(discovery, "discovery.describe"), { + ok: false, + code: "CONFIG_INVALID", + }); + assertEquals(existsSync(p.projected), false); + const selected = getExecutorAgentDescribeResultSchema().parse( + await request(discovery, "agent.describe", { agentId: "coder" }), + ); + assert(selected.ok); + } finally { + await discovery.close(); + await p.cleanup(); + } + }); + + it("preserves valid metadata while keeping collected import errors local", async () => { + const p = await project(); + await writeFile( + join(p.dir, "crew", "broken.ts"), + 'throw new Error("synthetic-private-import-diagnostic");\nexport default {};', + ); + const discovery = owner(p.dir, "writer"); + try { + const result = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(result.ok); + assertEquals(result.value.errorCount, 1); + assertEquals(discovery.getRuntime().errors.length, 1); + assertEquals(JSON.stringify(result).includes("synthetic-private-import-diagnostic"), false); + assertEquals(JSON.stringify(result).includes(p.dir), false); + } finally { + await discovery.close(); + await p.cleanup(); + } + }); + + it("rejects a competing default-scope owner without clearing the active runtime", async () => { + const p = await project(); + const first = owner(p.dir, "writer"); + const second = owner(p.dir, "writer"); + try { + await request(first, "discovery.describe"); + assertEquals(await request(second, "discovery.describe"), { + ok: false, + code: "EXECUTOR_DISCOVERY_BUSY", + }); + await second.close(); + assertEquals(first.getRuntime().agents.has("writer"), true); + assertEquals(agentRegistry.get("writer")?.id, "writer"); + } finally { + await second.close(); + await first.close(); + await p.cleanup(); + } + }); + + for (const fsType of ["veryfront-api", "github"] as const) { + it(`discovers constructor-local sources when application fs is ${fsType}`, async () => { + const p = await project(); + await writeFile( + join(p.dir, "veryfront.config.ts"), + `export default ${ + JSON.stringify({ + fs: fsType === "veryfront-api" ? { type: fsType, veryfront: {} } : { + type: fsType, + github: { token: "synthetic-unused-token", owner: "synthetic", repo: "synthetic" }, + }, + ai: { agents: { discovery: { paths: ["crew"] } } }, + }) + };`, + ); + const discovery = owner(p.dir, "writer"); + try { + const result = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(result.ok); + assertEquals(result.value.candidates, { + codeAgentIds: ["coder"], + markdownAgentIds: ["writer"], + }); + assertEquals( + result.value.definition.instructions.trim(), + "Synthetic markdown instructions.", + ); + } finally { + await discovery.close(); + await p.cleanup(); + } + }); + } + + it("keeps markdown fallback inside the bound source, including symlink targets", async () => { + const p = await project(); + const bound = join(p.dir, "nested", "project"); + await mkdir(join(bound, "agents"), { recursive: true }); + await mkdir(join(p.dir, "agents")); + await writeFile( + join(bound, "veryfront.config.ts"), + "export default { ai: { agents: { discovery: { enabled: false } } } };", + ); + await writeFile( + join(bound, "agents", "inside.md"), + "---\nname: Inside\n---\n\nSynthetic bound-source instructions.\n", + ); + const outside = join(p.dir, "agents", "outside.md"); + await writeFile( + outside, + "---\nname: Outside\n---\n\nSynthetic neighboring-source instructions.\n", + ); + await symlink(outside, join(bound, "agents", "outside-alias.md")); + const discovery = owner(bound, "inside"); + try { + assertEquals(await request(discovery, "agent.describe", { agentId: "outside" }), { + ok: false, + code: "AGENT_NOT_FOUND", + }); + assertEquals(await request(discovery, "agent.describe", { agentId: "outside-alias" }), { + ok: false, + code: "AGENT_NOT_FOUND", + }); + const result = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(result.ok); + assertEquals( + result.value.definition.instructions.trim(), + "Synthetic bound-source instructions.", + ); + assertEquals(result.value.candidates, { codeAgentIds: [], markdownAgentIds: [] }); + assertEquals(discovery.signal.aborted, false); + } finally { + await discovery.close(); + await p.cleanup(); + } + }); +}); From 518ec452171823ec81fedb02701c7d9eec15f0b6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 19:24:00 +0200 Subject: [PATCH 010/194] fix(agent): preserve curated model failures across executor channels --- src/agent/hosted/executor-model-bridge.ts | 77 +++- .../hosted/executor-model-errors.test.ts | 425 ++++++++++++++++++ src/agent/hosted/executor-model-errors.ts | 41 ++ src/agent/hosted/executor-model-schema.ts | 2 + src/chat/provider-error-registry.ts | 110 +++++ src/chat/provider-errors.ts | 53 +-- ...xecutor-model-error-classification.test.ts | 92 ++++ 7 files changed, 739 insertions(+), 61 deletions(-) create mode 100644 src/agent/hosted/executor-model-errors.test.ts create mode 100644 src/agent/hosted/executor-model-errors.ts create mode 100644 src/chat/provider-error-registry.ts create mode 100644 tests/integration/agent/executor-model-error-classification.test.ts diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 941b849efa..53f1968a34 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -1,5 +1,6 @@ import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { executorModelFailure, throwExecutorModelFailure } from "./executor-model-errors.ts"; import type { ExecutorChannel, ExecutorOperation, @@ -124,7 +125,11 @@ export function createExecutorModelBroker(options: { async handle(input, context) { const { modelId } = parseExecutorModelData(getExecutorModelRequestSchema(), input); context.signal.throwIfAborted(); - await getModel(modelId).prepare?.(context.signal); + try { + await getModel(modelId).prepare?.(context.signal); + } catch (error) { + return modelFailureOrThrow(error, context); + } return null; }, }], @@ -154,13 +159,15 @@ export function createExecutorModelBroker(options: { mode: "unary", async handle(input, context) { const call = parseCall(input, context); - const permit = await authorizeDispatch(call, "generate", context); - context.signal.throwIfAborted(); - permit?.assertActive(); - const result = await call.model.doGenerate({ - ...call.options, - abortSignal: context.signal, - }); + let result; + try { + const permit = await authorizeDispatch(call, "generate", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + result = await call.model.doGenerate({ ...call.options, abortSignal: context.signal }); + } catch (error) { + return modelFailureOrThrow(error, context); + } // Only the neutral result fields leave the broker. Native request and // response objects, headers, and transport diagnostics are never copied. const data = executorModelJson({ @@ -179,10 +186,16 @@ export function createExecutorModelBroker(options: { mode: "stream", async *handle(input, context) { const call = parseCall(input, context); - const permit = await authorizeDispatch(call, "stream", context); - context.signal.throwIfAborted(); - permit?.assertActive(); - const result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); + let result; + try { + const permit = await authorizeDispatch(call, "stream", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); + } catch (error) { + yield modelFailureOrThrow(error, context); + return; + } const reader = result.stream.getReader(); // Later reader.cancel() calls do not wait for the first call's provider cleanup. let cancellation: Promise | undefined; @@ -214,10 +227,12 @@ export function createExecutorModelBroker(options: { complete = true; return; } + throwProviderStreamError(next.value); const value = executorModelJson(next.value); - rejectStreamError(value); yield { type: "chunk", value }; } + } catch (error) { + yield modelFailureOrThrow(error, context); } finally { context.signal.removeEventListener("abort", cancel); if (!complete) await cancel().catch(() => {}); @@ -244,16 +259,34 @@ function modelMetadata(id: string, model: ModelRuntime) { }; } -function rejectStreamError(value: JsonValue, rawEnvelope = false): void { +function modelFailureOrThrow(error: unknown, context: ExecutorOperationContext) { + context.signal.throwIfAborted(); + const failure = executorModelFailure(error); + if (failure) return failure; + throw new TypeError("Managed model operation failed"); +} + +function throwProviderStreamError(value: unknown, rawEnvelope = false): void { + if (value === null || typeof value !== "object" || Array.isArray(value)) return; + const type = Object.getOwnPropertyDescriptor(value, "type")?.value; + // Normalized provider-tool failures are recoverable results, never transport failures. + if (type === "tool-error" && !rawEnvelope) return; + const error = Object.getOwnPropertyDescriptor(value, "error"); + if (type === "error" || error) throw error && "value" in error ? error.value : value; + const rawValue = Object.getOwnPropertyDescriptor(value, "rawValue")?.value; + if (type === "raw" && rawValue !== undefined) throwProviderStreamError(rawValue, true); +} + +/** Received chunks cannot classify failures or expose peer-supplied diagnostics. */ +function rejectReceivedStreamError(value: JsonValue, rawEnvelope = false): void { if (value === null || typeof value !== "object" || Array.isArray(value)) return; - // First-party provider-tool failures are normalized results; inference can - // continue with text and final usage. Raw provider errors remain fatal. if (value.type === "tool-error" && !rawEnvelope) return; if (value.type === "error" || Object.hasOwn(value, "error")) { - throw new TypeError("Managed model stream failed"); + throw new TypeError("Invalid managed model stream chunk"); + } + if (value.type === "raw" && value.rawValue !== undefined) { + rejectReceivedStreamError(value.rawValue, true); } - // Raw stream support still must not expose a provider's error envelope. - if (value.type === "raw" && value.rawValue !== undefined) rejectStreamError(value.rawValue, true); } /** @@ -357,12 +390,14 @@ function createExecutorModelRuntime( const result = await channel.request("model.prepare", { modelId: id }, { signal: combinedSignal, }); + throwExecutorModelFailure(result); if (result !== null) throw new TypeError("Invalid managed model preparation result"); }, async doGenerate(options: ModelRuntimeCallOptions) { const call = makeCall(options); assertActive(); const result = await channel.request("model.generate", call.input, { signal: call.signal }); + throwExecutorModelFailure(result); return parseExecutorModelData(getExecutorModelGenerateResultSchema(), result); }, async doStream(options: ModelRuntimeCallOptions) { @@ -373,6 +408,7 @@ function createExecutorModelRuntime( const first = await iterator.next(); if (first.done) throw new TypeError("Managed model stream start is missing"); const start = parseExecutorModelData(getExecutorModelStreamFrameSchema(), first.value); + throwExecutorModelFailure(start); if (start.type !== "start") throw new TypeError("Invalid managed model stream start"); const stream = new ReadableStream({ async pull(controller) { @@ -383,8 +419,9 @@ function createExecutorModelRuntime( return; } const frame = parseExecutorModelData(getExecutorModelStreamFrameSchema(), next.value); + throwExecutorModelFailure(frame); if (frame.type !== "chunk") throw new TypeError("Invalid managed model stream chunk"); - rejectStreamError(frame.value); + rejectReceivedStreamError(frame.value); controller.enqueue(frame.value); } catch (error) { controller.error(error); diff --git a/src/agent/hosted/executor-model-errors.test.ts b/src/agent/hosted/executor-model-errors.test.ts new file mode 100644 index 0000000000..eb37270822 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.test.ts @@ -0,0 +1,425 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { defineError, snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { + ProviderOverloadedError, + ProviderQuotaError, +} from "#veryfront/provider/runtime-loader/provider-http.ts"; +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { resolveRuntimeStreamErrorEvent } from "../runtime/chat-stream-handler.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "./executor-model-bridge.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const input = { prompt: [] }; +const privateDetail = "Synthetic private upstream detail"; + +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: privateDetail, + retryable: true, + retryAfterMs: 9000, + }); +} +function model( + overrides: Partial> = {}, +): ModelRuntime { + return { + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.resolve({}), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(c) { + c.close(); + }, + }), + }), + ...overrides, + }; +} + +async function connected( + runtime: ModelRuntime, + operations?: ReadonlyMap, +) { + const frames: string[] = []; + const transport = () => + new TransformStream({ + transform(value, controller) { + frames.push(new TextDecoder().decode(value)); + controller.enqueue(value); + }, + }); + const forward = transport(); + const backward = transport(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const receiver = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: operations ?? + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => runtime }), + }); + const resolver = operations + ? undefined + : await createExecutorModelRuntimeResolver({ channel: caller, allowedModelIds }); + return { + caller, + proxy: resolver?.(modelId), + frames, + async close() { + caller.close(); + await receiver.closed; + }, + }; +} + +function assertClassified(error: unknown, code: string, status: number) { + assertEquals(parseProviderError(error).code, code); + assertEquals(resolveRuntimeStreamErrorEvent(error).code, code); + assertEquals(snapshotVeryfrontError(error)?.slug, code.toLowerCase().replaceAll("_", "-")); + assertEquals(snapshotVeryfrontError(error)?.status, status); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(snapshotVeryfrontError(error)?.cause, undefined); +} + +describe("executor curated model failures", () => { + for (const phase of ["prepare", "generate", "stream setup"] as const) { + it(`preserves overload classification during ${phase}`, async () => { + const runtime = model({ + ...(phase === "prepare" ? { prepare: () => Promise.reject(overload()) } : {}), + ...(phase === "generate" ? { doGenerate: () => Promise.reject(overload()) } : {}), + ...(phase === "stream setup" ? { doStream: () => Promise.reject(overload()) } : {}), + }); + const channels = await connected(runtime); + try { + const error = await assertRejects(async () => { + if (phase === "prepare") await channels.proxy!.prepare!(); + else if (phase === "generate") await channels.proxy!.doGenerate(input); + else await channels.proxy!.doStream(input); + }); + assertClassified(error, "OVERLOADED_ERROR", 503); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("retryAfterMs"), false); + } finally { + await channels.close(); + } + }); + } + + it("preserves bounded credit, billing, context, and schema classifications", async () => { + const cases = [ + { + error: new Error( + 'Synthetic response {"slug":"insufficient-credits","error":"' + privateDetail + '"}', + ), + code: "INSUFFICIENT_CREDITS", + status: 402, + }, + { + error: { responseBody: '{"slug":"resource-limit-exceeded"}' }, + code: "RESOURCE_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("prompt is too long " + privateDetail), + code: "CONTEXT_LENGTH_EXCEEDED", + status: 413, + }, + { + error: new Error("invalid Veryfront schema " + privateDetail), + code: "PROJECT_SCHEMA_ERROR", + status: 400, + }, + { + error: new Error("response_format additionalProperties must be false " + privateDetail), + code: "OUTPUT_SCHEMA_NOT_CLOSED", + status: 400, + }, + { + error: new ProviderQuotaError({ + provider: "openai", + status: 429, + message: privateDetail, + retryable: false, + }), + code: "AI_PROVIDER_BILLING_ERROR", + status: 502, + }, + { + error: { type: "rate_limit_error", message: privateDetail }, + code: "RATE_LIMITED", + status: 429, + }, + { + error: new Error("assistant message prefill is unsupported " + privateDetail), + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + status: 400, + }, + { + error: { responseBody: '{"error":"AI provider spend limit reached"}' }, + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("workspace API usage limit has been reached " + privateDetail), + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + status: 502, + }, + ]; + for (const sample of cases) { + const channels = await connected(model({ doGenerate: () => Promise.reject(sample.error) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, sample.code, sample.status); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + } + }); + + for (const fatal of ["throw", "error part", "raw envelope"] as const) { + it(`preserves midstream ${fatal} classification and cleans up upstream`, async () => { + let cancelled = false; + let index = 0; + const channels = await connected( + model({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (index++ === 0) { + controller.enqueue({ type: "text-delta", delta: "Synthetic prefix" }); + } else if (fatal === "throw") { + controller.error(overload()); + } else {controller.enqueue( + fatal === "error part" ? { type: "error", error: overload() } : { + type: "raw", + rawValue: { + type: "error", + error: { type: "overloaded_error", message: privateDetail }, + }, + }, + );} + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }), + }), + ); + try { + const { stream } = await channels.proxy!.doStream(input); + const reader = stream.getReader(); + assertEquals((await reader.read()).value, { + type: "text-delta", + delta: "Synthetic prefix", + }); + const error = await assertRejects(() => reader.read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + await reader.cancel().catch(() => {}); + if (fatal !== "throw") assertEquals(cancelled, true); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + }); + } + + it("uses fixed diagnostics for curated registry slugs and ignores unknown registered codes", async () => { + const known = defineError({ + slug: "insufficient-credits", + category: "AGENT", + status: 599, + title: privateDetail, + }).create({ cause: privateDetail }); + const channels = await connected(model({ doGenerate: () => Promise.reject(known) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, "INSUFFICIENT_CREDITS", 402); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("599"), false); + } finally { + await channels.close(); + } + const unknown = defineError({ + slug: "synthetic-unknown-failure", + category: "AGENT", + status: 429, + title: privateDetail, + }).create(); + const other = await connected(model({ doGenerate: () => Promise.reject(unknown) })); + try { + const error = await assertRejects(async () => await other.proxy!.doGenerate(input)); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(other.frames.join("").includes(privateDetail), false); + } finally { + await other.close(); + } + }); + + it("rejects unknown or extended failure envelopes instead of trusting wire diagnostics", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.prepare", { + mode: "unary", + handle: () => ({ type: "failure", code: "OVERLOADED_ERROR", message: privateDetail }), + }); + operations.set("model.generate", { + mode: "unary", + handle: () => ({ type: "failure", code: "SYNTHETIC_UNKNOWN", status: 403 }), + }); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "failure", code: "OVERLOADED_ERROR", status: 599 }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const proxy = resolver(modelId)!; + for ( + const invoke of [ + () => proxy.prepare!(), + () => proxy.doGenerate(input), + () => proxy.doStream(input), + ] + ) { + const error = await assertRejects(async () => await invoke()); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + } + } finally { + await channels.close(); + } + }); + + for ( + const value of [ + { type: "error", error: privateDetail }, + { type: "error", error: { type: "overloaded_error", message: privateDetail } }, + { type: "raw", rawValue: { error: privateDetail } }, + { + type: "raw", + rawValue: { + type: "raw", + rawValue: { error: { type: "overloaded_error", message: privateDetail } }, + }, + }, + ] as JsonValue[] + ) { + it(`keeps received ${JSON.stringify(value).includes("overloaded_error") ? "structured" : "text"} ${JSON.stringify(value).includes("rawValue") ? "raw" : "error"} chunks opaque`, async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "chunk", value }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read(), TypeError); + assert(error instanceof TypeError); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + } finally { + await channels.close(); + } + }); + } + + it("classifies a received strict failure frame without trusting diagnostic chunks", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "failure", code: "OVERLOADED_ERROR" }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + } finally { + await channels.close(); + } + }); + + it("keeps unknown model and arbitrary channel errors opaque", async () => { + const channels = await connected( + model({ doGenerate: () => Promise.reject(new Error(privateDetail)) }), + ); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + const other = await connected( + model(), + new Map([["custom", { + mode: "unary", + handle() { + throw overload(); + }, + }]]), + ); + try { + const error = await assertRejects(() => other.caller.request("custom", {})); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await other.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-model-errors.ts b/src/agent/hosted/executor-model-errors.ts new file mode 100644 index 0000000000..897469f1c4 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.ts @@ -0,0 +1,41 @@ +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import { + CURATED_PROVIDER_FAILURE_CODES, + curatedProviderFailure, + type CuratedProviderFailureCode, +} from "#veryfront/chat/provider-error-registry.ts"; +import { defineError, type VeryfrontError } from "#veryfront/errors/types.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; + +export const getExecutorModelFailureSchema = defineSchema((v) => + v.object({ + type: v.literal("failure"), + code: v.enum(CURATED_PROVIDER_FAILURE_CODES), + }).strict() +); + +/** No message, body, cause, provider status, or response headers enter the envelope. */ +export function executorModelFailure( + error: unknown, +): { type: "failure"; code: CuratedProviderFailureCode } | undefined { + const code = parseProviderError(error).code; + const allowed = CURATED_PROVIDER_FAILURE_CODES.find((value) => value === code); + return allowed ? { type: "failure", code: allowed } : undefined; +} + +/** Reconstruct the existing registered-error shape using fixed local diagnostics. */ +export function createExecutorModelFailure(code: CuratedProviderFailureCode): VeryfrontError { + const failure = curatedProviderFailure(code); + return defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + status: failure.status, + title: failure.message, + }).create(); +} + +/** Only the exact bounded model failure envelope has authority to classify a reply. */ +export function throwExecutorModelFailure(value: unknown): void { + const result = getExecutorModelFailureSchema().safeParse(value); + if (result.success) throw createExecutorModelFailure(result.data.code); +} diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts index a1f09d4986..bc3c2b6dea 100644 --- a/src/agent/hosted/executor-model-schema.ts +++ b/src/agent/hosted/executor-model-schema.ts @@ -1,6 +1,7 @@ import type { InferSchema, Schema } 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 { getExecutorModelFailureSchema } from "./executor-model-errors.ts"; const MAX_MODELS = 128; const MAX_ITEMS = 1000; @@ -240,6 +241,7 @@ export const getExecutorModelGenerateResultSchema = defineSchema((v) => export const getExecutorModelStreamFrameSchema = defineSchema((v) => v.discriminatedUnion("type", [ + getExecutorModelFailureSchema(), v.object({ type: v.literal("start"), warnings: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), diff --git a/src/chat/provider-error-registry.ts b/src/chat/provider-error-registry.ts new file mode 100644 index 0000000000..d8fb299061 --- /dev/null +++ b/src/chat/provider-error-registry.ts @@ -0,0 +1,110 @@ +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; + +export const PROJECT_SCHEMA_ERROR = { + code: "PROJECT_SCHEMA_ERROR", + message: + "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", +} as const; + +export const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + message: + "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", +} as const; + +export const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { + code: "OUTPUT_SCHEMA_NOT_CLOSED", + message: + "The provider rejected the output schema because an object in it allows additional properties. " + + "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + + "built with defineSchema(), or set the property directly on a raw JSON Schema.", +} as const; + +export const AI_PROVIDER_SPEND_LIMIT_ERROR = { + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + message: + "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", + status: 402, +} as const; + +export const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + message: + "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", + status: 502, +} as const; + +export const AI_PROVIDER_BILLING_ERROR = { + code: "AI_PROVIDER_BILLING_ERROR", + message: + "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", + status: 502, +} as const; + +/** Codes transported across model boundaries; diagnostics are reconstructed locally. */ +export const CURATED_PROVIDER_FAILURE_CODES = [ + "OVERLOADED_ERROR", + "CONTEXT_LENGTH_EXCEEDED", + "INSUFFICIENT_CREDITS", + "RESOURCE_LIMIT_EXCEEDED", + "RATE_LIMITED", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", +] as const; +export type CuratedProviderFailureCode = typeof CURATED_PROVIDER_FAILURE_CODES[number]; + +const failures = { + OVERLOADED_ERROR: { + code: "OVERLOADED_ERROR", + message: "The LLM provider is currently overloaded", + status: 503, + }, + CONTEXT_LENGTH_EXCEEDED: { + code: "CONTEXT_LENGTH_EXCEEDED", + message: "Conversation is too long", + status: 413, + }, + INSUFFICIENT_CREDITS: { + code: "INSUFFICIENT_CREDITS", + message: "Insufficient AI credits", + status: 402, + }, + RESOURCE_LIMIT_EXCEEDED: { + code: "RESOURCE_LIMIT_EXCEEDED", + message: "Resource limit exceeded", + status: 402, + }, + RATE_LIMITED: { + code: "RATE_LIMITED", + message: "Too many requests. Please wait a moment and try again.", + status: 429, + }, + PROJECT_SCHEMA_ERROR: { ...PROJECT_SCHEMA_ERROR, status: 400 }, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL: { + ...MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + status: 400, + }, + OUTPUT_SCHEMA_NOT_CLOSED: { ...OUTPUT_SCHEMA_NOT_CLOSED_ERROR, status: 400 }, + AI_PROVIDER_SPEND_LIMIT_EXCEEDED: AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED: AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + AI_PROVIDER_BILLING_ERROR: AI_PROVIDER_BILLING_ERROR, +} as const; + +/** Return fixed local diagnostics; provider payload/status values are never forwarded. */ +export function curatedProviderFailure(code: CuratedProviderFailureCode) { + return { ...failures[code] }; +} + +/** Recognize only registered curated slugs, ignoring arbitrary code/status properties. */ +export function registeredProviderFailure(error: unknown) { + const snapshot = snapshotVeryfrontError(error); + if (!snapshot) return undefined; + const code = CURATED_PROVIDER_FAILURE_CODES.find((value) => + value.toLowerCase().replaceAll("_", "-") === snapshot.slug + ); + return code ? curatedProviderFailure(code) : undefined; +} diff --git a/src/chat/provider-errors.ts b/src/chat/provider-errors.ts index 7a860625bb..cbc12bd752 100644 --- a/src/chat/provider-errors.ts +++ b/src/chat/provider-errors.ts @@ -3,6 +3,15 @@ import { ProviderOverloadedError, ProviderQuotaError, } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { + AI_PROVIDER_BILLING_ERROR, + AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + OUTPUT_SCHEMA_NOT_CLOSED_ERROR, + PROJECT_SCHEMA_ERROR, + registeredProviderFailure, +} from "./provider-error-registry.ts"; export { safeJsonParse }; export type { SafeJsonParseResult } from "#veryfront/utils/json.ts"; @@ -18,47 +27,6 @@ const DEFAULT_EXTERNAL_SERVICE_ERROR = { message: "LLM provider service error", } as const; -const PROJECT_SCHEMA_ERROR = { - code: "PROJECT_SCHEMA_ERROR", - message: - "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", -} as const; - -const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { - code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", - message: - "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", -} as const; - -const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { - code: "OUTPUT_SCHEMA_NOT_CLOSED", - message: - "The provider rejected the output schema because an object in it allows additional properties. " + - "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + - "built with defineSchema(), or set the property directly on a raw JSON Schema.", -} as const; - -const AI_PROVIDER_SPEND_LIMIT_ERROR = { - code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", - message: - "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", - status: 402, -} as const; - -const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { - code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", - message: - "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", - status: 502, -} as const; - -const AI_PROVIDER_BILLING_ERROR = { - code: "AI_PROVIDER_BILLING_ERROR", - message: - "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", - status: 502, -} as const; - const MAX_PROVIDER_ERROR_DEPTH = 64; const MAX_PROVIDER_ERROR_TEXT_CHARS = 256 * 1024; const MAX_EMBEDDED_JSON_CANDIDATES = 32; @@ -483,6 +451,9 @@ function parseProviderErrorInner( return DEFAULT_EXTERNAL_SERVICE_ERROR; } + const registered = registeredProviderFailure(error); + if (registered) return registered; + if (error instanceof ProviderQuotaError) { return AI_PROVIDER_BILLING_ERROR; } diff --git a/tests/integration/agent/executor-model-error-classification.test.ts b/tests/integration/agent/executor-model-error-classification.test.ts new file mode 100644 index 0000000000..8365e6d88a --- /dev/null +++ b/tests/integration/agent/executor-model-error-classification.test.ts @@ -0,0 +1,92 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { ProviderOverloadedError } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const binding = { allocationId: "allocation-test", generation: 1, invocationId: "invocation-test" }; +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: "Synthetic private detail", + retryable: true, + }); +} + +describe("executor model agent error classification", () => { + for (const mode of ["generate", "stream", "midstream"] as const) { + it(`preserves overload through the real agent ${mode} runtime`, async () => { + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.reject(overload()), + doStream() { + if (mode !== "midstream") return Promise.reject(overload()); + let count = 0; + return Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (count++ === 0) { + controller.enqueue({ type: "text-delta", text: "Synthetic prefix" }); + } else controller.error(overload()); + }, + }, { highWaterMark: 0 }), + }); + }, + }), + }), + }); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: caller, + allowedModelIds, + }); + const runtime = new AgentRuntime("synthetic-agent", { + model: modelId, + system: "Synthetic system", + maxSteps: 1, + }, { resolveModelRuntime: resolver }); + if (mode === "generate") { + const error = await assertRejects(() => runtime.generate("Synthetic prompt")); + assertEquals(parseProviderError(error).code, "OVERLOADED_ERROR"); + assertEquals(snapshotVeryfrontError(error)?.status, 503); + } else { + const stream = await runtime.stream([{ + id: "message-test", + role: "user", + parts: [{ type: "text", text: "Synthetic prompt" }], + timestamp: 1, + }]); + const output = await new Response(stream).text(); + assert(output.includes('"code":"OVERLOADED_ERROR"')); + assertEquals(output.includes("Synthetic private detail"), false); + } + } finally { + caller.close(); + await broker.closed; + } + }); + } +}); From 265eba07058c90941be2faf8603c719039c83ac7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 19:42:17 +0200 Subject: [PATCH 011/194] fix(agent): preserve filename-derived executor agent identities --- src/agent/hosted/executor-discovery.test.ts | 33 +++++++++++++++++++++ src/agent/hosted/executor-discovery.ts | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index 16083cda38..f4af87c247 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -169,6 +169,39 @@ describe("executor discovery operations", () => { } }); + it("preserves filename-derived IDs for code agents without an explicit ID", async () => { + const discovered = agent({ system: "Filename-bound instructions", model: "openai/synthetic" }); + const originalId = discovered.id; + const f = fixture({ + agents: [discovered], + defaultAgentId: "filename-agent", + agentSource: "code", + }); + f.state.agents.clear(); + f.state.agents.set("filename-agent", discovered); + try { + for (const name of ["discovery.describe", "agent.describe"]) { + const result = await call( + f.owner, + name, + name === "agent.describe" ? { agentId: "filename-agent" } : {}, + ); + assert(result !== null && typeof result === "object" && !Array.isArray(result)); + assertEquals(result.ok, true); + const value = result.value; + assert(value !== null && typeof value === "object" && !Array.isArray(value)); + const definition = value.definition; + assert(definition !== null && typeof definition === "object" && !Array.isArray(definition)); + assertEquals(definition.id, "filename-agent"); + assertEquals(definition.instructions, "Filename-bound instructions"); + } + assertEquals(discovered.id, originalId); + assertEquals(f.cleanups, 0); + } finally { + await f.owner.close(); + } + }); + it("rejects wire paths, source replacement, and wrong bindings before discovery", async () => { const f = fixture(); try { diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 211a48869f..123f4562d1 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -151,10 +151,11 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut const found = discovery.agents.get(agentId); let definition: RuntimeAgentMarkdownDefinition; if (found && module.doesProjectAgentRuntimeAgentMatchSource(found, agentSource)) { - definition = await module.runWithProjectAgentRuntime( + const projected = await module.runWithProjectAgentRuntime( discovery, () => module.createRuntimeAgentDefinitionFromAgent(found), ); + definition = { ...projected, id: agentId }; } else { if (agentSource === "code") throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); const files = await import("../runtime/agent-definition-files.ts"); From 47b345b41940b965d045cca5a928c91b58bb8fba Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 19:24:00 +0200 Subject: [PATCH 012/194] fix(agent): preserve curated model failures across executor channels --- src/agent/hosted/executor-model-bridge.ts | 77 +++- .../hosted/executor-model-errors.test.ts | 425 ++++++++++++++++++ src/agent/hosted/executor-model-errors.ts | 41 ++ src/agent/hosted/executor-model-schema.ts | 2 + src/chat/provider-error-registry.ts | 110 +++++ src/chat/provider-errors.ts | 53 +-- ...xecutor-model-error-classification.test.ts | 92 ++++ 7 files changed, 739 insertions(+), 61 deletions(-) create mode 100644 src/agent/hosted/executor-model-errors.test.ts create mode 100644 src/agent/hosted/executor-model-errors.ts create mode 100644 src/chat/provider-error-registry.ts create mode 100644 tests/integration/agent/executor-model-error-classification.test.ts diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 941b849efa..53f1968a34 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -1,5 +1,6 @@ import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { executorModelFailure, throwExecutorModelFailure } from "./executor-model-errors.ts"; import type { ExecutorChannel, ExecutorOperation, @@ -124,7 +125,11 @@ export function createExecutorModelBroker(options: { async handle(input, context) { const { modelId } = parseExecutorModelData(getExecutorModelRequestSchema(), input); context.signal.throwIfAborted(); - await getModel(modelId).prepare?.(context.signal); + try { + await getModel(modelId).prepare?.(context.signal); + } catch (error) { + return modelFailureOrThrow(error, context); + } return null; }, }], @@ -154,13 +159,15 @@ export function createExecutorModelBroker(options: { mode: "unary", async handle(input, context) { const call = parseCall(input, context); - const permit = await authorizeDispatch(call, "generate", context); - context.signal.throwIfAborted(); - permit?.assertActive(); - const result = await call.model.doGenerate({ - ...call.options, - abortSignal: context.signal, - }); + let result; + try { + const permit = await authorizeDispatch(call, "generate", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + result = await call.model.doGenerate({ ...call.options, abortSignal: context.signal }); + } catch (error) { + return modelFailureOrThrow(error, context); + } // Only the neutral result fields leave the broker. Native request and // response objects, headers, and transport diagnostics are never copied. const data = executorModelJson({ @@ -179,10 +186,16 @@ export function createExecutorModelBroker(options: { mode: "stream", async *handle(input, context) { const call = parseCall(input, context); - const permit = await authorizeDispatch(call, "stream", context); - context.signal.throwIfAborted(); - permit?.assertActive(); - const result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); + let result; + try { + const permit = await authorizeDispatch(call, "stream", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); + } catch (error) { + yield modelFailureOrThrow(error, context); + return; + } const reader = result.stream.getReader(); // Later reader.cancel() calls do not wait for the first call's provider cleanup. let cancellation: Promise | undefined; @@ -214,10 +227,12 @@ export function createExecutorModelBroker(options: { complete = true; return; } + throwProviderStreamError(next.value); const value = executorModelJson(next.value); - rejectStreamError(value); yield { type: "chunk", value }; } + } catch (error) { + yield modelFailureOrThrow(error, context); } finally { context.signal.removeEventListener("abort", cancel); if (!complete) await cancel().catch(() => {}); @@ -244,16 +259,34 @@ function modelMetadata(id: string, model: ModelRuntime) { }; } -function rejectStreamError(value: JsonValue, rawEnvelope = false): void { +function modelFailureOrThrow(error: unknown, context: ExecutorOperationContext) { + context.signal.throwIfAborted(); + const failure = executorModelFailure(error); + if (failure) return failure; + throw new TypeError("Managed model operation failed"); +} + +function throwProviderStreamError(value: unknown, rawEnvelope = false): void { + if (value === null || typeof value !== "object" || Array.isArray(value)) return; + const type = Object.getOwnPropertyDescriptor(value, "type")?.value; + // Normalized provider-tool failures are recoverable results, never transport failures. + if (type === "tool-error" && !rawEnvelope) return; + const error = Object.getOwnPropertyDescriptor(value, "error"); + if (type === "error" || error) throw error && "value" in error ? error.value : value; + const rawValue = Object.getOwnPropertyDescriptor(value, "rawValue")?.value; + if (type === "raw" && rawValue !== undefined) throwProviderStreamError(rawValue, true); +} + +/** Received chunks cannot classify failures or expose peer-supplied diagnostics. */ +function rejectReceivedStreamError(value: JsonValue, rawEnvelope = false): void { if (value === null || typeof value !== "object" || Array.isArray(value)) return; - // First-party provider-tool failures are normalized results; inference can - // continue with text and final usage. Raw provider errors remain fatal. if (value.type === "tool-error" && !rawEnvelope) return; if (value.type === "error" || Object.hasOwn(value, "error")) { - throw new TypeError("Managed model stream failed"); + throw new TypeError("Invalid managed model stream chunk"); + } + if (value.type === "raw" && value.rawValue !== undefined) { + rejectReceivedStreamError(value.rawValue, true); } - // Raw stream support still must not expose a provider's error envelope. - if (value.type === "raw" && value.rawValue !== undefined) rejectStreamError(value.rawValue, true); } /** @@ -357,12 +390,14 @@ function createExecutorModelRuntime( const result = await channel.request("model.prepare", { modelId: id }, { signal: combinedSignal, }); + throwExecutorModelFailure(result); if (result !== null) throw new TypeError("Invalid managed model preparation result"); }, async doGenerate(options: ModelRuntimeCallOptions) { const call = makeCall(options); assertActive(); const result = await channel.request("model.generate", call.input, { signal: call.signal }); + throwExecutorModelFailure(result); return parseExecutorModelData(getExecutorModelGenerateResultSchema(), result); }, async doStream(options: ModelRuntimeCallOptions) { @@ -373,6 +408,7 @@ function createExecutorModelRuntime( const first = await iterator.next(); if (first.done) throw new TypeError("Managed model stream start is missing"); const start = parseExecutorModelData(getExecutorModelStreamFrameSchema(), first.value); + throwExecutorModelFailure(start); if (start.type !== "start") throw new TypeError("Invalid managed model stream start"); const stream = new ReadableStream({ async pull(controller) { @@ -383,8 +419,9 @@ function createExecutorModelRuntime( return; } const frame = parseExecutorModelData(getExecutorModelStreamFrameSchema(), next.value); + throwExecutorModelFailure(frame); if (frame.type !== "chunk") throw new TypeError("Invalid managed model stream chunk"); - rejectStreamError(frame.value); + rejectReceivedStreamError(frame.value); controller.enqueue(frame.value); } catch (error) { controller.error(error); diff --git a/src/agent/hosted/executor-model-errors.test.ts b/src/agent/hosted/executor-model-errors.test.ts new file mode 100644 index 0000000000..eb37270822 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.test.ts @@ -0,0 +1,425 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { defineError, snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { + ProviderOverloadedError, + ProviderQuotaError, +} from "#veryfront/provider/runtime-loader/provider-http.ts"; +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { resolveRuntimeStreamErrorEvent } from "../runtime/chat-stream-handler.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "./executor-model-bridge.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const input = { prompt: [] }; +const privateDetail = "Synthetic private upstream detail"; + +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: privateDetail, + retryable: true, + retryAfterMs: 9000, + }); +} +function model( + overrides: Partial> = {}, +): ModelRuntime { + return { + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.resolve({}), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(c) { + c.close(); + }, + }), + }), + ...overrides, + }; +} + +async function connected( + runtime: ModelRuntime, + operations?: ReadonlyMap, +) { + const frames: string[] = []; + const transport = () => + new TransformStream({ + transform(value, controller) { + frames.push(new TextDecoder().decode(value)); + controller.enqueue(value); + }, + }); + const forward = transport(); + const backward = transport(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const receiver = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: operations ?? + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => runtime }), + }); + const resolver = operations + ? undefined + : await createExecutorModelRuntimeResolver({ channel: caller, allowedModelIds }); + return { + caller, + proxy: resolver?.(modelId), + frames, + async close() { + caller.close(); + await receiver.closed; + }, + }; +} + +function assertClassified(error: unknown, code: string, status: number) { + assertEquals(parseProviderError(error).code, code); + assertEquals(resolveRuntimeStreamErrorEvent(error).code, code); + assertEquals(snapshotVeryfrontError(error)?.slug, code.toLowerCase().replaceAll("_", "-")); + assertEquals(snapshotVeryfrontError(error)?.status, status); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(snapshotVeryfrontError(error)?.cause, undefined); +} + +describe("executor curated model failures", () => { + for (const phase of ["prepare", "generate", "stream setup"] as const) { + it(`preserves overload classification during ${phase}`, async () => { + const runtime = model({ + ...(phase === "prepare" ? { prepare: () => Promise.reject(overload()) } : {}), + ...(phase === "generate" ? { doGenerate: () => Promise.reject(overload()) } : {}), + ...(phase === "stream setup" ? { doStream: () => Promise.reject(overload()) } : {}), + }); + const channels = await connected(runtime); + try { + const error = await assertRejects(async () => { + if (phase === "prepare") await channels.proxy!.prepare!(); + else if (phase === "generate") await channels.proxy!.doGenerate(input); + else await channels.proxy!.doStream(input); + }); + assertClassified(error, "OVERLOADED_ERROR", 503); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("retryAfterMs"), false); + } finally { + await channels.close(); + } + }); + } + + it("preserves bounded credit, billing, context, and schema classifications", async () => { + const cases = [ + { + error: new Error( + 'Synthetic response {"slug":"insufficient-credits","error":"' + privateDetail + '"}', + ), + code: "INSUFFICIENT_CREDITS", + status: 402, + }, + { + error: { responseBody: '{"slug":"resource-limit-exceeded"}' }, + code: "RESOURCE_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("prompt is too long " + privateDetail), + code: "CONTEXT_LENGTH_EXCEEDED", + status: 413, + }, + { + error: new Error("invalid Veryfront schema " + privateDetail), + code: "PROJECT_SCHEMA_ERROR", + status: 400, + }, + { + error: new Error("response_format additionalProperties must be false " + privateDetail), + code: "OUTPUT_SCHEMA_NOT_CLOSED", + status: 400, + }, + { + error: new ProviderQuotaError({ + provider: "openai", + status: 429, + message: privateDetail, + retryable: false, + }), + code: "AI_PROVIDER_BILLING_ERROR", + status: 502, + }, + { + error: { type: "rate_limit_error", message: privateDetail }, + code: "RATE_LIMITED", + status: 429, + }, + { + error: new Error("assistant message prefill is unsupported " + privateDetail), + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + status: 400, + }, + { + error: { responseBody: '{"error":"AI provider spend limit reached"}' }, + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("workspace API usage limit has been reached " + privateDetail), + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + status: 502, + }, + ]; + for (const sample of cases) { + const channels = await connected(model({ doGenerate: () => Promise.reject(sample.error) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, sample.code, sample.status); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + } + }); + + for (const fatal of ["throw", "error part", "raw envelope"] as const) { + it(`preserves midstream ${fatal} classification and cleans up upstream`, async () => { + let cancelled = false; + let index = 0; + const channels = await connected( + model({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (index++ === 0) { + controller.enqueue({ type: "text-delta", delta: "Synthetic prefix" }); + } else if (fatal === "throw") { + controller.error(overload()); + } else {controller.enqueue( + fatal === "error part" ? { type: "error", error: overload() } : { + type: "raw", + rawValue: { + type: "error", + error: { type: "overloaded_error", message: privateDetail }, + }, + }, + );} + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }), + }), + ); + try { + const { stream } = await channels.proxy!.doStream(input); + const reader = stream.getReader(); + assertEquals((await reader.read()).value, { + type: "text-delta", + delta: "Synthetic prefix", + }); + const error = await assertRejects(() => reader.read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + await reader.cancel().catch(() => {}); + if (fatal !== "throw") assertEquals(cancelled, true); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + }); + } + + it("uses fixed diagnostics for curated registry slugs and ignores unknown registered codes", async () => { + const known = defineError({ + slug: "insufficient-credits", + category: "AGENT", + status: 599, + title: privateDetail, + }).create({ cause: privateDetail }); + const channels = await connected(model({ doGenerate: () => Promise.reject(known) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, "INSUFFICIENT_CREDITS", 402); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("599"), false); + } finally { + await channels.close(); + } + const unknown = defineError({ + slug: "synthetic-unknown-failure", + category: "AGENT", + status: 429, + title: privateDetail, + }).create(); + const other = await connected(model({ doGenerate: () => Promise.reject(unknown) })); + try { + const error = await assertRejects(async () => await other.proxy!.doGenerate(input)); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(other.frames.join("").includes(privateDetail), false); + } finally { + await other.close(); + } + }); + + it("rejects unknown or extended failure envelopes instead of trusting wire diagnostics", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.prepare", { + mode: "unary", + handle: () => ({ type: "failure", code: "OVERLOADED_ERROR", message: privateDetail }), + }); + operations.set("model.generate", { + mode: "unary", + handle: () => ({ type: "failure", code: "SYNTHETIC_UNKNOWN", status: 403 }), + }); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "failure", code: "OVERLOADED_ERROR", status: 599 }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const proxy = resolver(modelId)!; + for ( + const invoke of [ + () => proxy.prepare!(), + () => proxy.doGenerate(input), + () => proxy.doStream(input), + ] + ) { + const error = await assertRejects(async () => await invoke()); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + } + } finally { + await channels.close(); + } + }); + + for ( + const value of [ + { type: "error", error: privateDetail }, + { type: "error", error: { type: "overloaded_error", message: privateDetail } }, + { type: "raw", rawValue: { error: privateDetail } }, + { + type: "raw", + rawValue: { + type: "raw", + rawValue: { error: { type: "overloaded_error", message: privateDetail } }, + }, + }, + ] as JsonValue[] + ) { + it(`keeps received ${JSON.stringify(value).includes("overloaded_error") ? "structured" : "text"} ${JSON.stringify(value).includes("rawValue") ? "raw" : "error"} chunks opaque`, async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "chunk", value }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read(), TypeError); + assert(error instanceof TypeError); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + } finally { + await channels.close(); + } + }); + } + + it("classifies a received strict failure frame without trusting diagnostic chunks", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "failure", code: "OVERLOADED_ERROR" }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + } finally { + await channels.close(); + } + }); + + it("keeps unknown model and arbitrary channel errors opaque", async () => { + const channels = await connected( + model({ doGenerate: () => Promise.reject(new Error(privateDetail)) }), + ); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + const other = await connected( + model(), + new Map([["custom", { + mode: "unary", + handle() { + throw overload(); + }, + }]]), + ); + try { + const error = await assertRejects(() => other.caller.request("custom", {})); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await other.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-model-errors.ts b/src/agent/hosted/executor-model-errors.ts new file mode 100644 index 0000000000..897469f1c4 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.ts @@ -0,0 +1,41 @@ +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import { + CURATED_PROVIDER_FAILURE_CODES, + curatedProviderFailure, + type CuratedProviderFailureCode, +} from "#veryfront/chat/provider-error-registry.ts"; +import { defineError, type VeryfrontError } from "#veryfront/errors/types.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; + +export const getExecutorModelFailureSchema = defineSchema((v) => + v.object({ + type: v.literal("failure"), + code: v.enum(CURATED_PROVIDER_FAILURE_CODES), + }).strict() +); + +/** No message, body, cause, provider status, or response headers enter the envelope. */ +export function executorModelFailure( + error: unknown, +): { type: "failure"; code: CuratedProviderFailureCode } | undefined { + const code = parseProviderError(error).code; + const allowed = CURATED_PROVIDER_FAILURE_CODES.find((value) => value === code); + return allowed ? { type: "failure", code: allowed } : undefined; +} + +/** Reconstruct the existing registered-error shape using fixed local diagnostics. */ +export function createExecutorModelFailure(code: CuratedProviderFailureCode): VeryfrontError { + const failure = curatedProviderFailure(code); + return defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + status: failure.status, + title: failure.message, + }).create(); +} + +/** Only the exact bounded model failure envelope has authority to classify a reply. */ +export function throwExecutorModelFailure(value: unknown): void { + const result = getExecutorModelFailureSchema().safeParse(value); + if (result.success) throw createExecutorModelFailure(result.data.code); +} diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts index a1f09d4986..bc3c2b6dea 100644 --- a/src/agent/hosted/executor-model-schema.ts +++ b/src/agent/hosted/executor-model-schema.ts @@ -1,6 +1,7 @@ import type { InferSchema, Schema } 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 { getExecutorModelFailureSchema } from "./executor-model-errors.ts"; const MAX_MODELS = 128; const MAX_ITEMS = 1000; @@ -240,6 +241,7 @@ export const getExecutorModelGenerateResultSchema = defineSchema((v) => export const getExecutorModelStreamFrameSchema = defineSchema((v) => v.discriminatedUnion("type", [ + getExecutorModelFailureSchema(), v.object({ type: v.literal("start"), warnings: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), diff --git a/src/chat/provider-error-registry.ts b/src/chat/provider-error-registry.ts new file mode 100644 index 0000000000..d8fb299061 --- /dev/null +++ b/src/chat/provider-error-registry.ts @@ -0,0 +1,110 @@ +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; + +export const PROJECT_SCHEMA_ERROR = { + code: "PROJECT_SCHEMA_ERROR", + message: + "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", +} as const; + +export const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + message: + "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", +} as const; + +export const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { + code: "OUTPUT_SCHEMA_NOT_CLOSED", + message: + "The provider rejected the output schema because an object in it allows additional properties. " + + "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + + "built with defineSchema(), or set the property directly on a raw JSON Schema.", +} as const; + +export const AI_PROVIDER_SPEND_LIMIT_ERROR = { + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + message: + "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", + status: 402, +} as const; + +export const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + message: + "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", + status: 502, +} as const; + +export const AI_PROVIDER_BILLING_ERROR = { + code: "AI_PROVIDER_BILLING_ERROR", + message: + "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", + status: 502, +} as const; + +/** Codes transported across model boundaries; diagnostics are reconstructed locally. */ +export const CURATED_PROVIDER_FAILURE_CODES = [ + "OVERLOADED_ERROR", + "CONTEXT_LENGTH_EXCEEDED", + "INSUFFICIENT_CREDITS", + "RESOURCE_LIMIT_EXCEEDED", + "RATE_LIMITED", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", +] as const; +export type CuratedProviderFailureCode = typeof CURATED_PROVIDER_FAILURE_CODES[number]; + +const failures = { + OVERLOADED_ERROR: { + code: "OVERLOADED_ERROR", + message: "The LLM provider is currently overloaded", + status: 503, + }, + CONTEXT_LENGTH_EXCEEDED: { + code: "CONTEXT_LENGTH_EXCEEDED", + message: "Conversation is too long", + status: 413, + }, + INSUFFICIENT_CREDITS: { + code: "INSUFFICIENT_CREDITS", + message: "Insufficient AI credits", + status: 402, + }, + RESOURCE_LIMIT_EXCEEDED: { + code: "RESOURCE_LIMIT_EXCEEDED", + message: "Resource limit exceeded", + status: 402, + }, + RATE_LIMITED: { + code: "RATE_LIMITED", + message: "Too many requests. Please wait a moment and try again.", + status: 429, + }, + PROJECT_SCHEMA_ERROR: { ...PROJECT_SCHEMA_ERROR, status: 400 }, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL: { + ...MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + status: 400, + }, + OUTPUT_SCHEMA_NOT_CLOSED: { ...OUTPUT_SCHEMA_NOT_CLOSED_ERROR, status: 400 }, + AI_PROVIDER_SPEND_LIMIT_EXCEEDED: AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED: AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + AI_PROVIDER_BILLING_ERROR: AI_PROVIDER_BILLING_ERROR, +} as const; + +/** Return fixed local diagnostics; provider payload/status values are never forwarded. */ +export function curatedProviderFailure(code: CuratedProviderFailureCode) { + return { ...failures[code] }; +} + +/** Recognize only registered curated slugs, ignoring arbitrary code/status properties. */ +export function registeredProviderFailure(error: unknown) { + const snapshot = snapshotVeryfrontError(error); + if (!snapshot) return undefined; + const code = CURATED_PROVIDER_FAILURE_CODES.find((value) => + value.toLowerCase().replaceAll("_", "-") === snapshot.slug + ); + return code ? curatedProviderFailure(code) : undefined; +} diff --git a/src/chat/provider-errors.ts b/src/chat/provider-errors.ts index 7a860625bb..cbc12bd752 100644 --- a/src/chat/provider-errors.ts +++ b/src/chat/provider-errors.ts @@ -3,6 +3,15 @@ import { ProviderOverloadedError, ProviderQuotaError, } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { + AI_PROVIDER_BILLING_ERROR, + AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + OUTPUT_SCHEMA_NOT_CLOSED_ERROR, + PROJECT_SCHEMA_ERROR, + registeredProviderFailure, +} from "./provider-error-registry.ts"; export { safeJsonParse }; export type { SafeJsonParseResult } from "#veryfront/utils/json.ts"; @@ -18,47 +27,6 @@ const DEFAULT_EXTERNAL_SERVICE_ERROR = { message: "LLM provider service error", } as const; -const PROJECT_SCHEMA_ERROR = { - code: "PROJECT_SCHEMA_ERROR", - message: - "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", -} as const; - -const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { - code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", - message: - "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", -} as const; - -const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { - code: "OUTPUT_SCHEMA_NOT_CLOSED", - message: - "The provider rejected the output schema because an object in it allows additional properties. " + - "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + - "built with defineSchema(), or set the property directly on a raw JSON Schema.", -} as const; - -const AI_PROVIDER_SPEND_LIMIT_ERROR = { - code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", - message: - "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", - status: 402, -} as const; - -const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { - code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", - message: - "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", - status: 502, -} as const; - -const AI_PROVIDER_BILLING_ERROR = { - code: "AI_PROVIDER_BILLING_ERROR", - message: - "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", - status: 502, -} as const; - const MAX_PROVIDER_ERROR_DEPTH = 64; const MAX_PROVIDER_ERROR_TEXT_CHARS = 256 * 1024; const MAX_EMBEDDED_JSON_CANDIDATES = 32; @@ -483,6 +451,9 @@ function parseProviderErrorInner( return DEFAULT_EXTERNAL_SERVICE_ERROR; } + const registered = registeredProviderFailure(error); + if (registered) return registered; + if (error instanceof ProviderQuotaError) { return AI_PROVIDER_BILLING_ERROR; } diff --git a/tests/integration/agent/executor-model-error-classification.test.ts b/tests/integration/agent/executor-model-error-classification.test.ts new file mode 100644 index 0000000000..8365e6d88a --- /dev/null +++ b/tests/integration/agent/executor-model-error-classification.test.ts @@ -0,0 +1,92 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { ProviderOverloadedError } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const binding = { allocationId: "allocation-test", generation: 1, invocationId: "invocation-test" }; +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: "Synthetic private detail", + retryable: true, + }); +} + +describe("executor model agent error classification", () => { + for (const mode of ["generate", "stream", "midstream"] as const) { + it(`preserves overload through the real agent ${mode} runtime`, async () => { + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.reject(overload()), + doStream() { + if (mode !== "midstream") return Promise.reject(overload()); + let count = 0; + return Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (count++ === 0) { + controller.enqueue({ type: "text-delta", text: "Synthetic prefix" }); + } else controller.error(overload()); + }, + }, { highWaterMark: 0 }), + }); + }, + }), + }), + }); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: caller, + allowedModelIds, + }); + const runtime = new AgentRuntime("synthetic-agent", { + model: modelId, + system: "Synthetic system", + maxSteps: 1, + }, { resolveModelRuntime: resolver }); + if (mode === "generate") { + const error = await assertRejects(() => runtime.generate("Synthetic prompt")); + assertEquals(parseProviderError(error).code, "OVERLOADED_ERROR"); + assertEquals(snapshotVeryfrontError(error)?.status, 503); + } else { + const stream = await runtime.stream([{ + id: "message-test", + role: "user", + parts: [{ type: "text", text: "Synthetic prompt" }], + timestamp: 1, + }]); + const output = await new Response(stream).text(); + assert(output.includes('"code":"OVERLOADED_ERROR"')); + assertEquals(output.includes("Synthetic private detail"), false); + } + } finally { + caller.close(); + await broker.closed; + } + }); + } +}); From bed6b290881fc793c15f27ec2b9f3825d99b723a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 19:42:17 +0200 Subject: [PATCH 013/194] fix(agent): preserve filename-derived executor agent identities --- src/agent/hosted/executor-discovery.test.ts | 33 +++++++++++++++++++++ src/agent/hosted/executor-discovery.ts | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index 16083cda38..f4af87c247 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -169,6 +169,39 @@ describe("executor discovery operations", () => { } }); + it("preserves filename-derived IDs for code agents without an explicit ID", async () => { + const discovered = agent({ system: "Filename-bound instructions", model: "openai/synthetic" }); + const originalId = discovered.id; + const f = fixture({ + agents: [discovered], + defaultAgentId: "filename-agent", + agentSource: "code", + }); + f.state.agents.clear(); + f.state.agents.set("filename-agent", discovered); + try { + for (const name of ["discovery.describe", "agent.describe"]) { + const result = await call( + f.owner, + name, + name === "agent.describe" ? { agentId: "filename-agent" } : {}, + ); + assert(result !== null && typeof result === "object" && !Array.isArray(result)); + assertEquals(result.ok, true); + const value = result.value; + assert(value !== null && typeof value === "object" && !Array.isArray(value)); + const definition = value.definition; + assert(definition !== null && typeof definition === "object" && !Array.isArray(definition)); + assertEquals(definition.id, "filename-agent"); + assertEquals(definition.instructions, "Filename-bound instructions"); + } + assertEquals(discovered.id, originalId); + assertEquals(f.cleanups, 0); + } finally { + await f.owner.close(); + } + }); + it("rejects wire paths, source replacement, and wrong bindings before discovery", async () => { const f = fixture(); try { diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 211a48869f..123f4562d1 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -151,10 +151,11 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut const found = discovery.agents.get(agentId); let definition: RuntimeAgentMarkdownDefinition; if (found && module.doesProjectAgentRuntimeAgentMatchSource(found, agentSource)) { - definition = await module.runWithProjectAgentRuntime( + const projected = await module.runWithProjectAgentRuntime( discovery, () => module.createRuntimeAgentDefinitionFromAgent(found), ); + definition = { ...projected, id: agentId }; } else { if (agentSource === "code") throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); const files = await import("../runtime/agent-definition-files.ts"); From 6424833948468429e4be210f9a52053f7e5e2b86 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 21:58:20 +0200 Subject: [PATCH 014/194] perf(agent): reuse executor stream encoders --- src/agent/hosted/executor-agent-bridge.ts | 4 +++- src/agent/streaming/executor-data-stream.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 27f230e957..f5f79ee68b 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -20,6 +20,8 @@ import { parseExecutorAgentData, } from "./executor-agent-schema.ts"; +const textEncoder = new TextEncoder(); + async function startExecutorRuntimeStream( start: () => Promise>, signal: AbortSignal, @@ -171,7 +173,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { terminal ||= event.type === "message-finish" || event.type === "finish" || event.type === "error"; controller.enqueue( - new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + textEncoder.encode(`data: ${JSON.stringify(event)}\n\n`), ); } else if (frame.type === "complete") { if (!terminal || !(await iterator.next()).done) { diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index da044a428b..a62fd9d7a0 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -5,6 +5,8 @@ import { } from "../hosted/executor-agent-schema.ts"; import { parseExecutorDataEvent } from "./executor-data-schema.ts"; +const textEncoder = new TextEncoder(); + /** @internal Strict bounded SSE reader for the executor's runtime stream. */ export async function* readExecutorDataEvents( stream: ReadableStream, @@ -43,7 +45,7 @@ export async function* readExecutorDataEvents( while ((separator = pending.indexOf("\n\n")) !== -1) { const block = pending.slice(0, separator); pending = pending.slice(separator + 2); - if (new TextEncoder().encode(block).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { + if (textEncoder.encode(block).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } const lines = block.split("\n"); @@ -57,7 +59,7 @@ export async function* readExecutorDataEvents( event.type === "error"; yield event; } - if (new TextEncoder().encode(pending).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { + if (textEncoder.encode(pending).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } } From afcff15f87b528d587e4da99cb27c891ee68888f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:01:56 +0200 Subject: [PATCH 015/194] fix(agent): preserve discovery when its cache reaches capacity --- src/agent/hosted/executor-discovery.test.ts | 2 + src/agent/hosted/executor-discovery.ts | 5 +- .../agent/executor-discovery.test.ts | 57 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index f4af87c247..47fdd2df1e 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -449,6 +449,8 @@ describe("executor discovery operations", () => { ok: false, code: "EXECUTOR_DISCOVERY_INVALID_OUTPUT", }); + assertEquals(f.owner.signal.aborted, true); + assertEquals(f.cleanups, 1); } finally { await f.owner.close(); } diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 123f4562d1..45ed98e489 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -147,6 +147,9 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut async function describeAgent(discovery: ProjectAgentRuntimeDiscovery, agentId: string) { const cached = definitions.get(agentId); if (cached) return cached; + if (definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { + throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_BUSY"); + } const module = await helpers(); const found = discovery.agents.get(agentId); let definition: RuntimeAgentMarkdownDefinition; @@ -189,7 +192,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } assertActive(); const parsed = parseDiscoveryData(getExecutorAgentDefinitionSchema(), definition, true); - if (parsed.id !== agentId || definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { + if (parsed.id !== agentId) { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_OUTPUT"); } definitions.set(agentId, parsed); diff --git a/tests/integration/agent/executor-discovery.test.ts b/tests/integration/agent/executor-discovery.test.ts index 9e1d5e67ba..512dfdc6a4 100644 --- a/tests/integration/agent/executor-discovery.test.ts +++ b/tests/integration/agent/executor-discovery.test.ts @@ -263,4 +263,61 @@ describe("isolated executor local project discovery", () => { await p.cleanup(); } }); + + it("keeps cached definitions usable when markdown fallback reaches the cache limit", async () => { + const p = await project(); + const discovery = owner(p.dir, "writer"); + try { + await mkdir(join(p.dir, "agents")); + const summary = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(summary.ok); + for (let i = 0; i < 256; i++) { + const agentId = `fallback-${i}`; + await writeFile( + join(p.dir, "agents", `${agentId}.md`), + "---\nname: Fallback\n---\n\nSynthetic fallback instructions.\n", + ); + // The default definition and 255 fallback definitions fill the cache. + if (i < 255) { + const result = getExecutorAgentDescribeResultSchema().parse( + await request(discovery, "agent.describe", { agentId }), + ); + assert(result.ok); + assertEquals(result.value.definition.id, agentId); + } + } + const cached = await request(discovery, "agent.describe", { agentId: "fallback-0" }); + await writeFile( + join(p.dir, "agents", "fallback-0.md"), + "---\nname: Changed\n---\n\nChanged fallback instructions.\n", + ); + + assertEquals(await request(discovery, "agent.describe", { agentId: "fallback-255" }), { + ok: false, + code: "EXECUTOR_DISCOVERY_BUSY", + }); + assertEquals(await request(discovery, "agent.describe", { agentId: "coder" }), { + ok: false, + code: "EXECUTOR_DISCOVERY_BUSY", + }); + assertEquals(existsSync(p.projected), false); + assertEquals(discovery.signal.aborted, false); + assertEquals(agentRegistry.get("writer")?.id, "writer"); + assertEquals(discovery.getRuntime().agents.has("writer"), true); + assertEquals( + await request(discovery, "agent.describe", { agentId: "fallback-0" }), + cached, + ); + assertEquals( + getExecutorDiscoveryResultSchema().parse(await request(discovery, "discovery.describe")), + summary, + ); + } finally { + await discovery.close(); + await p.cleanup(); + } + assertEquals(agentRegistry.get("writer"), undefined); + }); }); From a45101d8d5b10bc7a15a5b669bece1b13e2d2334 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:09:58 +0200 Subject: [PATCH 016/194] fix(agent): preserve curated failures in hosted executor runtime --- src/agent/hosted/executor-model-bridge.ts | 77 +++- .../hosted/executor-model-errors.test.ts | 425 ++++++++++++++++++ src/agent/hosted/executor-model-errors.ts | 41 ++ src/agent/hosted/executor-model-schema.ts | 2 + src/chat/provider-error-registry.ts | 110 +++++ src/chat/provider-errors.ts | 53 +-- ...xecutor-model-error-classification.test.ts | 92 ++++ 7 files changed, 739 insertions(+), 61 deletions(-) create mode 100644 src/agent/hosted/executor-model-errors.test.ts create mode 100644 src/agent/hosted/executor-model-errors.ts create mode 100644 src/chat/provider-error-registry.ts create mode 100644 tests/integration/agent/executor-model-error-classification.test.ts diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 941b849efa..53f1968a34 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -1,5 +1,6 @@ import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { executorModelFailure, throwExecutorModelFailure } from "./executor-model-errors.ts"; import type { ExecutorChannel, ExecutorOperation, @@ -124,7 +125,11 @@ export function createExecutorModelBroker(options: { async handle(input, context) { const { modelId } = parseExecutorModelData(getExecutorModelRequestSchema(), input); context.signal.throwIfAborted(); - await getModel(modelId).prepare?.(context.signal); + try { + await getModel(modelId).prepare?.(context.signal); + } catch (error) { + return modelFailureOrThrow(error, context); + } return null; }, }], @@ -154,13 +159,15 @@ export function createExecutorModelBroker(options: { mode: "unary", async handle(input, context) { const call = parseCall(input, context); - const permit = await authorizeDispatch(call, "generate", context); - context.signal.throwIfAborted(); - permit?.assertActive(); - const result = await call.model.doGenerate({ - ...call.options, - abortSignal: context.signal, - }); + let result; + try { + const permit = await authorizeDispatch(call, "generate", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + result = await call.model.doGenerate({ ...call.options, abortSignal: context.signal }); + } catch (error) { + return modelFailureOrThrow(error, context); + } // Only the neutral result fields leave the broker. Native request and // response objects, headers, and transport diagnostics are never copied. const data = executorModelJson({ @@ -179,10 +186,16 @@ export function createExecutorModelBroker(options: { mode: "stream", async *handle(input, context) { const call = parseCall(input, context); - const permit = await authorizeDispatch(call, "stream", context); - context.signal.throwIfAborted(); - permit?.assertActive(); - const result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); + let result; + try { + const permit = await authorizeDispatch(call, "stream", context); + context.signal.throwIfAborted(); + permit?.assertActive(); + result = await call.model.doStream({ ...call.options, abortSignal: context.signal }); + } catch (error) { + yield modelFailureOrThrow(error, context); + return; + } const reader = result.stream.getReader(); // Later reader.cancel() calls do not wait for the first call's provider cleanup. let cancellation: Promise | undefined; @@ -214,10 +227,12 @@ export function createExecutorModelBroker(options: { complete = true; return; } + throwProviderStreamError(next.value); const value = executorModelJson(next.value); - rejectStreamError(value); yield { type: "chunk", value }; } + } catch (error) { + yield modelFailureOrThrow(error, context); } finally { context.signal.removeEventListener("abort", cancel); if (!complete) await cancel().catch(() => {}); @@ -244,16 +259,34 @@ function modelMetadata(id: string, model: ModelRuntime) { }; } -function rejectStreamError(value: JsonValue, rawEnvelope = false): void { +function modelFailureOrThrow(error: unknown, context: ExecutorOperationContext) { + context.signal.throwIfAborted(); + const failure = executorModelFailure(error); + if (failure) return failure; + throw new TypeError("Managed model operation failed"); +} + +function throwProviderStreamError(value: unknown, rawEnvelope = false): void { + if (value === null || typeof value !== "object" || Array.isArray(value)) return; + const type = Object.getOwnPropertyDescriptor(value, "type")?.value; + // Normalized provider-tool failures are recoverable results, never transport failures. + if (type === "tool-error" && !rawEnvelope) return; + const error = Object.getOwnPropertyDescriptor(value, "error"); + if (type === "error" || error) throw error && "value" in error ? error.value : value; + const rawValue = Object.getOwnPropertyDescriptor(value, "rawValue")?.value; + if (type === "raw" && rawValue !== undefined) throwProviderStreamError(rawValue, true); +} + +/** Received chunks cannot classify failures or expose peer-supplied diagnostics. */ +function rejectReceivedStreamError(value: JsonValue, rawEnvelope = false): void { if (value === null || typeof value !== "object" || Array.isArray(value)) return; - // First-party provider-tool failures are normalized results; inference can - // continue with text and final usage. Raw provider errors remain fatal. if (value.type === "tool-error" && !rawEnvelope) return; if (value.type === "error" || Object.hasOwn(value, "error")) { - throw new TypeError("Managed model stream failed"); + throw new TypeError("Invalid managed model stream chunk"); + } + if (value.type === "raw" && value.rawValue !== undefined) { + rejectReceivedStreamError(value.rawValue, true); } - // Raw stream support still must not expose a provider's error envelope. - if (value.type === "raw" && value.rawValue !== undefined) rejectStreamError(value.rawValue, true); } /** @@ -357,12 +390,14 @@ function createExecutorModelRuntime( const result = await channel.request("model.prepare", { modelId: id }, { signal: combinedSignal, }); + throwExecutorModelFailure(result); if (result !== null) throw new TypeError("Invalid managed model preparation result"); }, async doGenerate(options: ModelRuntimeCallOptions) { const call = makeCall(options); assertActive(); const result = await channel.request("model.generate", call.input, { signal: call.signal }); + throwExecutorModelFailure(result); return parseExecutorModelData(getExecutorModelGenerateResultSchema(), result); }, async doStream(options: ModelRuntimeCallOptions) { @@ -373,6 +408,7 @@ function createExecutorModelRuntime( const first = await iterator.next(); if (first.done) throw new TypeError("Managed model stream start is missing"); const start = parseExecutorModelData(getExecutorModelStreamFrameSchema(), first.value); + throwExecutorModelFailure(start); if (start.type !== "start") throw new TypeError("Invalid managed model stream start"); const stream = new ReadableStream({ async pull(controller) { @@ -383,8 +419,9 @@ function createExecutorModelRuntime( return; } const frame = parseExecutorModelData(getExecutorModelStreamFrameSchema(), next.value); + throwExecutorModelFailure(frame); if (frame.type !== "chunk") throw new TypeError("Invalid managed model stream chunk"); - rejectStreamError(frame.value); + rejectReceivedStreamError(frame.value); controller.enqueue(frame.value); } catch (error) { controller.error(error); diff --git a/src/agent/hosted/executor-model-errors.test.ts b/src/agent/hosted/executor-model-errors.test.ts new file mode 100644 index 0000000000..eb37270822 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.test.ts @@ -0,0 +1,425 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { defineError, snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { + ProviderOverloadedError, + ProviderQuotaError, +} from "#veryfront/provider/runtime-loader/provider-http.ts"; +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { resolveRuntimeStreamErrorEvent } from "../runtime/chat-stream-handler.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "./executor-model-bridge.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const input = { prompt: [] }; +const privateDetail = "Synthetic private upstream detail"; + +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: privateDetail, + retryable: true, + retryAfterMs: 9000, + }); +} +function model( + overrides: Partial> = {}, +): ModelRuntime { + return { + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.resolve({}), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(c) { + c.close(); + }, + }), + }), + ...overrides, + }; +} + +async function connected( + runtime: ModelRuntime, + operations?: ReadonlyMap, +) { + const frames: string[] = []; + const transport = () => + new TransformStream({ + transform(value, controller) { + frames.push(new TextDecoder().decode(value)); + controller.enqueue(value); + }, + }); + const forward = transport(); + const backward = transport(); + const binding = { + allocationId: "allocation-test", + generation: 1, + invocationId: "invocation-test", + }; + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const receiver = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: operations ?? + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => runtime }), + }); + const resolver = operations + ? undefined + : await createExecutorModelRuntimeResolver({ channel: caller, allowedModelIds }); + return { + caller, + proxy: resolver?.(modelId), + frames, + async close() { + caller.close(); + await receiver.closed; + }, + }; +} + +function assertClassified(error: unknown, code: string, status: number) { + assertEquals(parseProviderError(error).code, code); + assertEquals(resolveRuntimeStreamErrorEvent(error).code, code); + assertEquals(snapshotVeryfrontError(error)?.slug, code.toLowerCase().replaceAll("_", "-")); + assertEquals(snapshotVeryfrontError(error)?.status, status); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(snapshotVeryfrontError(error)?.cause, undefined); +} + +describe("executor curated model failures", () => { + for (const phase of ["prepare", "generate", "stream setup"] as const) { + it(`preserves overload classification during ${phase}`, async () => { + const runtime = model({ + ...(phase === "prepare" ? { prepare: () => Promise.reject(overload()) } : {}), + ...(phase === "generate" ? { doGenerate: () => Promise.reject(overload()) } : {}), + ...(phase === "stream setup" ? { doStream: () => Promise.reject(overload()) } : {}), + }); + const channels = await connected(runtime); + try { + const error = await assertRejects(async () => { + if (phase === "prepare") await channels.proxy!.prepare!(); + else if (phase === "generate") await channels.proxy!.doGenerate(input); + else await channels.proxy!.doStream(input); + }); + assertClassified(error, "OVERLOADED_ERROR", 503); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("retryAfterMs"), false); + } finally { + await channels.close(); + } + }); + } + + it("preserves bounded credit, billing, context, and schema classifications", async () => { + const cases = [ + { + error: new Error( + 'Synthetic response {"slug":"insufficient-credits","error":"' + privateDetail + '"}', + ), + code: "INSUFFICIENT_CREDITS", + status: 402, + }, + { + error: { responseBody: '{"slug":"resource-limit-exceeded"}' }, + code: "RESOURCE_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("prompt is too long " + privateDetail), + code: "CONTEXT_LENGTH_EXCEEDED", + status: 413, + }, + { + error: new Error("invalid Veryfront schema " + privateDetail), + code: "PROJECT_SCHEMA_ERROR", + status: 400, + }, + { + error: new Error("response_format additionalProperties must be false " + privateDetail), + code: "OUTPUT_SCHEMA_NOT_CLOSED", + status: 400, + }, + { + error: new ProviderQuotaError({ + provider: "openai", + status: 429, + message: privateDetail, + retryable: false, + }), + code: "AI_PROVIDER_BILLING_ERROR", + status: 502, + }, + { + error: { type: "rate_limit_error", message: privateDetail }, + code: "RATE_LIMITED", + status: 429, + }, + { + error: new Error("assistant message prefill is unsupported " + privateDetail), + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + status: 400, + }, + { + error: { responseBody: '{"error":"AI provider spend limit reached"}' }, + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + status: 402, + }, + { + error: new Error("workspace API usage limit has been reached " + privateDetail), + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + status: 502, + }, + ]; + for (const sample of cases) { + const channels = await connected(model({ doGenerate: () => Promise.reject(sample.error) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, sample.code, sample.status); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + } + }); + + for (const fatal of ["throw", "error part", "raw envelope"] as const) { + it(`preserves midstream ${fatal} classification and cleans up upstream`, async () => { + let cancelled = false; + let index = 0; + const channels = await connected( + model({ + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (index++ === 0) { + controller.enqueue({ type: "text-delta", delta: "Synthetic prefix" }); + } else if (fatal === "throw") { + controller.error(overload()); + } else {controller.enqueue( + fatal === "error part" ? { type: "error", error: overload() } : { + type: "raw", + rawValue: { + type: "error", + error: { type: "overloaded_error", message: privateDetail }, + }, + }, + );} + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), + }), + }), + ); + try { + const { stream } = await channels.proxy!.doStream(input); + const reader = stream.getReader(); + assertEquals((await reader.read()).value, { + type: "text-delta", + delta: "Synthetic prefix", + }); + const error = await assertRejects(() => reader.read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + await reader.cancel().catch(() => {}); + if (fatal !== "throw") assertEquals(cancelled, true); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + }); + } + + it("uses fixed diagnostics for curated registry slugs and ignores unknown registered codes", async () => { + const known = defineError({ + slug: "insufficient-credits", + category: "AGENT", + status: 599, + title: privateDetail, + }).create({ cause: privateDetail }); + const channels = await connected(model({ doGenerate: () => Promise.reject(known) })); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assertClassified(error, "INSUFFICIENT_CREDITS", 402); + assertEquals(channels.frames.join("").includes(privateDetail), false); + assertEquals(channels.frames.join("").includes("599"), false); + } finally { + await channels.close(); + } + const unknown = defineError({ + slug: "synthetic-unknown-failure", + category: "AGENT", + status: 429, + title: privateDetail, + }).create(); + const other = await connected(model({ doGenerate: () => Promise.reject(unknown) })); + try { + const error = await assertRejects(async () => await other.proxy!.doGenerate(input)); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(other.frames.join("").includes(privateDetail), false); + } finally { + await other.close(); + } + }); + + it("rejects unknown or extended failure envelopes instead of trusting wire diagnostics", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.prepare", { + mode: "unary", + handle: () => ({ type: "failure", code: "OVERLOADED_ERROR", message: privateDetail }), + }); + operations.set("model.generate", { + mode: "unary", + handle: () => ({ type: "failure", code: "SYNTHETIC_UNKNOWN", status: 403 }), + }); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "failure", code: "OVERLOADED_ERROR", status: 599 }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const proxy = resolver(modelId)!; + for ( + const invoke of [ + () => proxy.prepare!(), + () => proxy.doGenerate(input), + () => proxy.doStream(input), + ] + ) { + const error = await assertRejects(async () => await invoke()); + assertEquals(snapshotVeryfrontError(error), null); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assert(error instanceof Error); + assertEquals(error.message.includes(privateDetail), false); + } + } finally { + await channels.close(); + } + }); + + for ( + const value of [ + { type: "error", error: privateDetail }, + { type: "error", error: { type: "overloaded_error", message: privateDetail } }, + { type: "raw", rawValue: { error: privateDetail } }, + { + type: "raw", + rawValue: { + type: "raw", + rawValue: { error: { type: "overloaded_error", message: privateDetail } }, + }, + }, + ] as JsonValue[] + ) { + it(`keeps received ${JSON.stringify(value).includes("overloaded_error") ? "structured" : "text"} ${JSON.stringify(value).includes("rawValue") ? "raw" : "error"} chunks opaque`, async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "chunk", value }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read(), TypeError); + assert(error instanceof TypeError); + assertEquals(error.message.includes(privateDetail), false); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(snapshotVeryfrontError(error), null); + } finally { + await channels.close(); + } + }); + } + + it("classifies a received strict failure frame without trusting diagnostic chunks", async () => { + const operations = new Map( + createExecutorModelBroker({ allowedModelIds, resolveModelRuntime: () => model() }), + ); + operations.set("model.stream", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { type: "start" }; + yield { type: "failure", code: "OVERLOADED_ERROR" }; + }, + }); + const channels = await connected(model(), operations); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: channels.caller, + allowedModelIds, + }); + const { stream } = await resolver(modelId)!.doStream(input); + const error = await assertRejects(() => stream.getReader().read()); + assertClassified(error, "OVERLOADED_ERROR", 503); + } finally { + await channels.close(); + } + }); + + it("keeps unknown model and arbitrary channel errors opaque", async () => { + const channels = await connected( + model({ doGenerate: () => Promise.reject(new Error(privateDetail)) }), + ); + try { + const error = await assertRejects(async () => await channels.proxy!.doGenerate(input)); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + assertEquals(parseProviderError(error).code, "EXTERNAL_SERVICE_ERROR"); + assertEquals(channels.frames.join("").includes(privateDetail), false); + } finally { + await channels.close(); + } + const other = await connected( + model(), + new Map([["custom", { + mode: "unary", + handle() { + throw overload(); + }, + }]]), + ); + try { + const error = await assertRejects(() => other.caller.request("custom", {})); + assert(error instanceof Error); + assertEquals(error.message, "Executor call operation-failed"); + } finally { + await other.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-model-errors.ts b/src/agent/hosted/executor-model-errors.ts new file mode 100644 index 0000000000..897469f1c4 --- /dev/null +++ b/src/agent/hosted/executor-model-errors.ts @@ -0,0 +1,41 @@ +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; +import { + CURATED_PROVIDER_FAILURE_CODES, + curatedProviderFailure, + type CuratedProviderFailureCode, +} from "#veryfront/chat/provider-error-registry.ts"; +import { defineError, type VeryfrontError } from "#veryfront/errors/types.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; + +export const getExecutorModelFailureSchema = defineSchema((v) => + v.object({ + type: v.literal("failure"), + code: v.enum(CURATED_PROVIDER_FAILURE_CODES), + }).strict() +); + +/** No message, body, cause, provider status, or response headers enter the envelope. */ +export function executorModelFailure( + error: unknown, +): { type: "failure"; code: CuratedProviderFailureCode } | undefined { + const code = parseProviderError(error).code; + const allowed = CURATED_PROVIDER_FAILURE_CODES.find((value) => value === code); + return allowed ? { type: "failure", code: allowed } : undefined; +} + +/** Reconstruct the existing registered-error shape using fixed local diagnostics. */ +export function createExecutorModelFailure(code: CuratedProviderFailureCode): VeryfrontError { + const failure = curatedProviderFailure(code); + return defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + status: failure.status, + title: failure.message, + }).create(); +} + +/** Only the exact bounded model failure envelope has authority to classify a reply. */ +export function throwExecutorModelFailure(value: unknown): void { + const result = getExecutorModelFailureSchema().safeParse(value); + if (result.success) throw createExecutorModelFailure(result.data.code); +} diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts index a1f09d4986..bc3c2b6dea 100644 --- a/src/agent/hosted/executor-model-schema.ts +++ b/src/agent/hosted/executor-model-schema.ts @@ -1,6 +1,7 @@ import type { InferSchema, Schema } 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 { getExecutorModelFailureSchema } from "./executor-model-errors.ts"; const MAX_MODELS = 128; const MAX_ITEMS = 1000; @@ -240,6 +241,7 @@ export const getExecutorModelGenerateResultSchema = defineSchema((v) => export const getExecutorModelStreamFrameSchema = defineSchema((v) => v.discriminatedUnion("type", [ + getExecutorModelFailureSchema(), v.object({ type: v.literal("start"), warnings: v.array(getJsonValueSchema()).max(MAX_ITEMS).optional(), diff --git a/src/chat/provider-error-registry.ts b/src/chat/provider-error-registry.ts new file mode 100644 index 0000000000..d8fb299061 --- /dev/null +++ b/src/chat/provider-error-registry.ts @@ -0,0 +1,110 @@ +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; + +export const PROJECT_SCHEMA_ERROR = { + code: "PROJECT_SCHEMA_ERROR", + message: + "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", +} as const; + +export const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { + code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + message: + "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", +} as const; + +export const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { + code: "OUTPUT_SCHEMA_NOT_CLOSED", + message: + "The provider rejected the output schema because an object in it allows additional properties. " + + "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + + "built with defineSchema(), or set the property directly on a raw JSON Schema.", +} as const; + +export const AI_PROVIDER_SPEND_LIMIT_ERROR = { + code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + message: + "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", + status: 402, +} as const; + +export const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { + code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + message: + "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", + status: 502, +} as const; + +export const AI_PROVIDER_BILLING_ERROR = { + code: "AI_PROVIDER_BILLING_ERROR", + message: + "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", + status: 502, +} as const; + +/** Codes transported across model boundaries; diagnostics are reconstructed locally. */ +export const CURATED_PROVIDER_FAILURE_CODES = [ + "OVERLOADED_ERROR", + "CONTEXT_LENGTH_EXCEEDED", + "INSUFFICIENT_CREDITS", + "RESOURCE_LIMIT_EXCEEDED", + "RATE_LIMITED", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", +] as const; +export type CuratedProviderFailureCode = typeof CURATED_PROVIDER_FAILURE_CODES[number]; + +const failures = { + OVERLOADED_ERROR: { + code: "OVERLOADED_ERROR", + message: "The LLM provider is currently overloaded", + status: 503, + }, + CONTEXT_LENGTH_EXCEEDED: { + code: "CONTEXT_LENGTH_EXCEEDED", + message: "Conversation is too long", + status: 413, + }, + INSUFFICIENT_CREDITS: { + code: "INSUFFICIENT_CREDITS", + message: "Insufficient AI credits", + status: 402, + }, + RESOURCE_LIMIT_EXCEEDED: { + code: "RESOURCE_LIMIT_EXCEEDED", + message: "Resource limit exceeded", + status: 402, + }, + RATE_LIMITED: { + code: "RATE_LIMITED", + message: "Too many requests. Please wait a moment and try again.", + status: 429, + }, + PROJECT_SCHEMA_ERROR: { ...PROJECT_SCHEMA_ERROR, status: 400 }, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL: { + ...MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + status: 400, + }, + OUTPUT_SCHEMA_NOT_CLOSED: { ...OUTPUT_SCHEMA_NOT_CLOSED_ERROR, status: 400 }, + AI_PROVIDER_SPEND_LIMIT_EXCEEDED: AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED: AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + AI_PROVIDER_BILLING_ERROR: AI_PROVIDER_BILLING_ERROR, +} as const; + +/** Return fixed local diagnostics; provider payload/status values are never forwarded. */ +export function curatedProviderFailure(code: CuratedProviderFailureCode) { + return { ...failures[code] }; +} + +/** Recognize only registered curated slugs, ignoring arbitrary code/status properties. */ +export function registeredProviderFailure(error: unknown) { + const snapshot = snapshotVeryfrontError(error); + if (!snapshot) return undefined; + const code = CURATED_PROVIDER_FAILURE_CODES.find((value) => + value.toLowerCase().replaceAll("_", "-") === snapshot.slug + ); + return code ? curatedProviderFailure(code) : undefined; +} diff --git a/src/chat/provider-errors.ts b/src/chat/provider-errors.ts index 7a860625bb..cbc12bd752 100644 --- a/src/chat/provider-errors.ts +++ b/src/chat/provider-errors.ts @@ -3,6 +3,15 @@ import { ProviderOverloadedError, ProviderQuotaError, } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { + AI_PROVIDER_BILLING_ERROR, + AI_PROVIDER_SPEND_LIMIT_ERROR, + AI_PROVIDER_WORKSPACE_LIMIT_ERROR, + MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR, + OUTPUT_SCHEMA_NOT_CLOSED_ERROR, + PROJECT_SCHEMA_ERROR, + registeredProviderFailure, +} from "./provider-error-registry.ts"; export { safeJsonParse }; export type { SafeJsonParseResult } from "#veryfront/utils/json.ts"; @@ -18,47 +27,6 @@ const DEFAULT_EXTERNAL_SERVICE_ERROR = { message: "LLM provider service error", } as const; -const PROJECT_SCHEMA_ERROR = { - code: "PROJECT_SCHEMA_ERROR", - message: - "Project code has an invalid Veryfront schema. Update the schema to use defineSchema(), then run the agent again.", -} as const; - -const MODEL_UNSUPPORTED_ASSISTANT_PREFILL_ERROR = { - code: "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", - message: - "The selected model does not support assistant-message prefill. Start a new user message or choose a compatible model.", -} as const; - -const OUTPUT_SCHEMA_NOT_CLOSED_ERROR = { - code: "OUTPUT_SCHEMA_NOT_CLOSED", - message: - "The provider rejected the output schema because an object in it allows additional properties. " + - "Set additionalProperties: false on that object -- add .strict() if the outputSchema was " + - "built with defineSchema(), or set the property directly on a raw JSON Schema.", -} as const; - -const AI_PROVIDER_SPEND_LIMIT_ERROR = { - code: "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", - message: - "The AI provider spend limit has been reached. Try again later or ask an administrator to raise the AI provider spend limit.", - status: 402, -} as const; - -const AI_PROVIDER_WORKSPACE_LIMIT_ERROR = { - code: "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", - message: - "The AI provider workspace API usage limit has been reached. Wait for the limit to reset, or ask an administrator to raise the workspace limit.", - status: 502, -} as const; - -const AI_PROVIDER_BILLING_ERROR = { - code: "AI_PROVIDER_BILLING_ERROR", - message: - "The configured AI provider account cannot process this request. Try a different model, or ask an administrator to check provider billing.", - status: 502, -} as const; - const MAX_PROVIDER_ERROR_DEPTH = 64; const MAX_PROVIDER_ERROR_TEXT_CHARS = 256 * 1024; const MAX_EMBEDDED_JSON_CANDIDATES = 32; @@ -483,6 +451,9 @@ function parseProviderErrorInner( return DEFAULT_EXTERNAL_SERVICE_ERROR; } + const registered = registeredProviderFailure(error); + if (registered) return registered; + if (error instanceof ProviderQuotaError) { return AI_PROVIDER_BILLING_ERROR; } diff --git a/tests/integration/agent/executor-model-error-classification.test.ts b/tests/integration/agent/executor-model-error-classification.test.ts new file mode 100644 index 0000000000..8365e6d88a --- /dev/null +++ b/tests/integration/agent/executor-model-error-classification.test.ts @@ -0,0 +1,92 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { ProviderOverloadedError } from "#veryfront/provider/runtime-loader/provider-http.ts"; +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const allowedModelIds = new Set([modelId]); +const binding = { allocationId: "allocation-test", generation: 1, invocationId: "invocation-test" }; +function overload() { + return new ProviderOverloadedError({ + provider: "openai", + status: 529, + message: "Synthetic private detail", + retryable: true, + }); +} + +describe("executor model agent error classification", () => { + for (const mode of ["generate", "stream", "midstream"] as const) { + it(`preserves overload through the real agent ${mode} runtime`, async () => { + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + provider: "veryfront-cloud", + modelId: "synthetic", + modelProvider: "openai", + doGenerate: () => Promise.reject(overload()), + doStream() { + if (mode !== "midstream") return Promise.reject(overload()); + let count = 0; + return Promise.resolve({ + stream: new ReadableStream({ + pull(controller) { + if (count++ === 0) { + controller.enqueue({ type: "text-delta", text: "Synthetic prefix" }); + } else controller.error(overload()); + }, + }, { highWaterMark: 0 }), + }); + }, + }), + }), + }); + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: caller, + allowedModelIds, + }); + const runtime = new AgentRuntime("synthetic-agent", { + model: modelId, + system: "Synthetic system", + maxSteps: 1, + }, { resolveModelRuntime: resolver }); + if (mode === "generate") { + const error = await assertRejects(() => runtime.generate("Synthetic prompt")); + assertEquals(parseProviderError(error).code, "OVERLOADED_ERROR"); + assertEquals(snapshotVeryfrontError(error)?.status, 503); + } else { + const stream = await runtime.stream([{ + id: "message-test", + role: "user", + parts: [{ type: "text", text: "Synthetic prompt" }], + timestamp: 1, + }]); + const output = await new Response(stream).text(); + assert(output.includes('"code":"OVERLOADED_ERROR"')); + assertEquals(output.includes("Synthetic private detail"), false); + } + } finally { + caller.close(); + await broker.closed; + } + }); + } +}); From 17417a48c43b27ac880c294c1be343c7930822d8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:09:59 +0200 Subject: [PATCH 017/194] fix(agent): sanitize hosted setup error titles --- src/agent/hosted/durable-chat-run-start.ts | 5 ++-- src/agent/service/routes.test.ts | 35 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/agent/hosted/durable-chat-run-start.ts b/src/agent/hosted/durable-chat-run-start.ts index fd37564d3d..26eb97a785 100644 --- a/src/agent/hosted/durable-chat-run-start.ts +++ b/src/agent/hosted/durable-chat-run-start.ts @@ -1,6 +1,7 @@ import { parseProviderError } from "../../chat/provider-errors.ts"; import { INPUT_VALIDATION_FAILED, INVALID_ARGUMENT } from "#veryfront/errors"; import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { sanitizeBoundedDiagnosticText } from "#veryfront/errors/diagnostic-policy.ts"; import { compactHistoricalUiMessageToolInputs, type HistoricalToolInputCompactionDiagnostic, @@ -108,8 +109,8 @@ export function classifyHostedChatSetupError( return { code: snapshot.slug.toUpperCase().replaceAll("-", "_"), status: snapshot.status, - // The registry title is stable; request/provider details stay out of SSE. - message: snapshot.title, + // Error titles can be customized after registration. + message: sanitizeBoundedDiagnosticText(snapshot.title), }; } diff --git a/src/agent/service/routes.test.ts b/src/agent/service/routes.test.ts index a3b20d2555..0ee17c5c1d 100644 --- a/src/agent/service/routes.test.ts +++ b/src/agent/service/routes.test.ts @@ -1,5 +1,6 @@ import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; +import { PERMISSION_DENIED } from "#veryfront/errors"; import { createDetachedRunTracker } from "./detached-run-tracker.ts"; import { createHostedAgentServiceRouteSet } from "./routes.ts"; import { type HostedServiceAuthenticatedRequest, HostedServiceAuthError } from "./auth.ts"; @@ -270,6 +271,40 @@ it("agent service routes classify AG-UI setup failures", async () => { ); }); +it("agent service routes preserve registered AG-UI setup error titles and codes", async () => { + const { routeSet } = createRouteSet({ + prepareExecution: () => + Promise.reject(PERMISSION_DENIED.create({ detail: "Synthetic private setup detail" })), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, 403); + const output = await response.text(); + assertStringIncludes(output, '"message":"File/resource permission denied"'); + assertStringIncludes(output, '"code":"PERMISSION_DENIED"'); + assertEquals(output.includes("Synthetic private setup detail"), false); +}); + +it("agent service routes redact mutable AG-UI setup error titles", async () => { + const error = PERMISSION_DENIED.create(); + const privateValue = "synthetic-private-credential"; + error.title = `Synthetic setup failure: Bearer ${privateValue}`; + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(error), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, 403); + const output = await response.text(); + assertEquals(output.includes(privateValue), false); + assertStringIncludes(output, '"message":"Synthetic setup failure: Bearer [REDACTED]"'); + assertStringIncludes(output, '"code":"PERMISSION_DENIED"'); +}); + Deno.test("agent service routes ignore client-controlled AG-UI target agent ids", async () => { const { routeSet, preparedRequests } = createRouteSet(); const response = await routeSet.handleAgUiRequest( From a899534c548c5640fb367cbedb2c404a1eaca7ff Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:09:59 +0200 Subject: [PATCH 018/194] fix(agent): sanitize hosted setup error titles --- src/agent/hosted/durable-chat-run-start.ts | 5 ++-- src/agent/service/routes.test.ts | 35 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/agent/hosted/durable-chat-run-start.ts b/src/agent/hosted/durable-chat-run-start.ts index fd37564d3d..26eb97a785 100644 --- a/src/agent/hosted/durable-chat-run-start.ts +++ b/src/agent/hosted/durable-chat-run-start.ts @@ -1,6 +1,7 @@ import { parseProviderError } from "../../chat/provider-errors.ts"; import { INPUT_VALIDATION_FAILED, INVALID_ARGUMENT } from "#veryfront/errors"; import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { sanitizeBoundedDiagnosticText } from "#veryfront/errors/diagnostic-policy.ts"; import { compactHistoricalUiMessageToolInputs, type HistoricalToolInputCompactionDiagnostic, @@ -108,8 +109,8 @@ export function classifyHostedChatSetupError( return { code: snapshot.slug.toUpperCase().replaceAll("-", "_"), status: snapshot.status, - // The registry title is stable; request/provider details stay out of SSE. - message: snapshot.title, + // Error titles can be customized after registration. + message: sanitizeBoundedDiagnosticText(snapshot.title), }; } diff --git a/src/agent/service/routes.test.ts b/src/agent/service/routes.test.ts index a3b20d2555..0ee17c5c1d 100644 --- a/src/agent/service/routes.test.ts +++ b/src/agent/service/routes.test.ts @@ -1,5 +1,6 @@ import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; +import { PERMISSION_DENIED } from "#veryfront/errors"; import { createDetachedRunTracker } from "./detached-run-tracker.ts"; import { createHostedAgentServiceRouteSet } from "./routes.ts"; import { type HostedServiceAuthenticatedRequest, HostedServiceAuthError } from "./auth.ts"; @@ -270,6 +271,40 @@ it("agent service routes classify AG-UI setup failures", async () => { ); }); +it("agent service routes preserve registered AG-UI setup error titles and codes", async () => { + const { routeSet } = createRouteSet({ + prepareExecution: () => + Promise.reject(PERMISSION_DENIED.create({ detail: "Synthetic private setup detail" })), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, 403); + const output = await response.text(); + assertStringIncludes(output, '"message":"File/resource permission denied"'); + assertStringIncludes(output, '"code":"PERMISSION_DENIED"'); + assertEquals(output.includes("Synthetic private setup detail"), false); +}); + +it("agent service routes redact mutable AG-UI setup error titles", async () => { + const error = PERMISSION_DENIED.create(); + const privateValue = "synthetic-private-credential"; + error.title = `Synthetic setup failure: Bearer ${privateValue}`; + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(error), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, 403); + const output = await response.text(); + assertEquals(output.includes(privateValue), false); + assertStringIncludes(output, '"message":"Synthetic setup failure: Bearer [REDACTED]"'); + assertStringIncludes(output, '"code":"PERMISSION_DENIED"'); +}); + Deno.test("agent service routes ignore client-controlled AG-UI target agent ids", async () => { const { routeSet, preparedRequests } = createRouteSet(); const response = await routeSet.handleAgUiRequest( From 836111a3600a5e5beeb3193865ea764b8ad80385 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:13:56 +0200 Subject: [PATCH 019/194] perf(agent): reuse executor stream text encoders --- src/agent/hosted/executor-agent-bridge.ts | 4 +++- src/agent/streaming/executor-data-stream.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 27f230e957..f5f79ee68b 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -20,6 +20,8 @@ import { parseExecutorAgentData, } from "./executor-agent-schema.ts"; +const textEncoder = new TextEncoder(); + async function startExecutorRuntimeStream( start: () => Promise>, signal: AbortSignal, @@ -171,7 +173,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { terminal ||= event.type === "message-finish" || event.type === "finish" || event.type === "error"; controller.enqueue( - new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + textEncoder.encode(`data: ${JSON.stringify(event)}\n\n`), ); } else if (frame.type === "complete") { if (!terminal || !(await iterator.next()).done) { diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index da044a428b..a62fd9d7a0 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -5,6 +5,8 @@ import { } from "../hosted/executor-agent-schema.ts"; import { parseExecutorDataEvent } from "./executor-data-schema.ts"; +const textEncoder = new TextEncoder(); + /** @internal Strict bounded SSE reader for the executor's runtime stream. */ export async function* readExecutorDataEvents( stream: ReadableStream, @@ -43,7 +45,7 @@ export async function* readExecutorDataEvents( while ((separator = pending.indexOf("\n\n")) !== -1) { const block = pending.slice(0, separator); pending = pending.slice(separator + 2); - if (new TextEncoder().encode(block).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { + if (textEncoder.encode(block).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } const lines = block.split("\n"); @@ -57,7 +59,7 @@ export async function* readExecutorDataEvents( event.type === "error"; yield event; } - if (new TextEncoder().encode(pending).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { + if (textEncoder.encode(pending).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } } From 84258584891023e4ad16d7c9d8b8eec5e26ac7ae Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:15:06 +0200 Subject: [PATCH 020/194] fix(agent): preserve discovery sessions at cache capacity --- src/agent/hosted/executor-discovery.ts | 5 ++- .../agent/executor-discovery.test.ts | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 123f4562d1..45ed98e489 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -147,6 +147,9 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut async function describeAgent(discovery: ProjectAgentRuntimeDiscovery, agentId: string) { const cached = definitions.get(agentId); if (cached) return cached; + if (definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { + throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_BUSY"); + } const module = await helpers(); const found = discovery.agents.get(agentId); let definition: RuntimeAgentMarkdownDefinition; @@ -189,7 +192,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } assertActive(); const parsed = parseDiscoveryData(getExecutorAgentDefinitionSchema(), definition, true); - if (parsed.id !== agentId || definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { + if (parsed.id !== agentId) { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_OUTPUT"); } definitions.set(agentId, parsed); diff --git a/tests/integration/agent/executor-discovery.test.ts b/tests/integration/agent/executor-discovery.test.ts index 9e1d5e67ba..4a646392e3 100644 --- a/tests/integration/agent/executor-discovery.test.ts +++ b/tests/integration/agent/executor-discovery.test.ts @@ -10,6 +10,7 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; import { agentRegistry } from "#veryfront/agent/composition/index.ts"; import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; import { + EXECUTOR_DISCOVERY_MAX_AGENTS, ExecutorDiscoveryError, getExecutorAgentDescribeResultSchema, getExecutorDiscoveryResultSchema, @@ -219,6 +220,49 @@ describe("isolated executor local project discovery", () => { }); } + it("keeps cached agents available when markdown descriptions reach capacity", async () => { + const p = await project(); + const discovery = owner(p.dir, "agent-0"); + try { + await writeFile( + join(p.dir, "veryfront.config.ts"), + "export default { ai: { agents: { discovery: { enabled: false } } } };", + ); + await mkdir(join(p.dir, "agents")); + for (let index = 0; index <= EXECUTOR_DISCOVERY_MAX_AGENTS; index++) { + await writeFile( + join(p.dir, "agents", `agent-${index}.md`), + `---\nname: Agent ${index}\n---\n\nSynthetic instructions ${index}.\n`, + ); + } + for (let index = 0; index < EXECUTOR_DISCOVERY_MAX_AGENTS; index++) { + const result = getExecutorAgentDescribeResultSchema().parse( + await request(discovery, "agent.describe", { agentId: `agent-${index}` }), + ); + assert(result.ok); + assertEquals(result.value.definition.id, `agent-${index}`); + } + assertEquals( + await request(discovery, "agent.describe", { + agentId: `agent-${EXECUTOR_DISCOVERY_MAX_AGENTS}`, + }), + { ok: false, code: "EXECUTOR_DISCOVERY_BUSY" }, + ); + assertEquals(discovery.signal.aborted, false); + const cached = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(cached.ok); + assertEquals(cached.value.definition.id, "agent-0"); + assertEquals(cached.value.definition.instructions.trim(), "Synthetic instructions 0."); + assertEquals(cached.value.candidates, { codeAgentIds: [], markdownAgentIds: [] }); + assertEquals(discovery.getRuntime().agents.size, 0); + } finally { + await discovery.close(); + await p.cleanup(); + } + }); + it("keeps markdown fallback inside the bound source, including symlink targets", async () => { const p = await project(); const bound = join(p.dir, "nested", "project"); From 4d9d5642fdecf7cd07f3a4260daaed530ac94051 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:17:30 +0200 Subject: [PATCH 021/194] fix(agent): classify project configuration loader failures --- src/agent/hosted/executor-discovery-schema.ts | 5 ++- .../agent/executor-discovery.test.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts index f5225a97f1..aceb1fcd55 100644 --- a/src/agent/hosted/executor-discovery-schema.ts +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -40,7 +40,10 @@ export class ExecutorDiscoveryError extends VeryfrontError { export function discoveryFailureCode(error: unknown): FailureCode { if (error instanceof ExecutorDiscoveryError) return error.code; const slug = snapshotVeryfrontError(error)?.slug; - if (slug === "config-invalid") return "CONFIG_INVALID"; + if ( + slug === "config-invalid" || slug === "config-validation-failed" || + slug === "config-parse-error" + ) return "CONFIG_INVALID"; if (slug === "agent-not-found") return "AGENT_NOT_FOUND"; return "EXECUTOR_DISCOVERY_FAILED"; } diff --git a/tests/integration/agent/executor-discovery.test.ts b/tests/integration/agent/executor-discovery.test.ts index 512dfdc6a4..1019ba5762 100644 --- a/tests/integration/agent/executor-discovery.test.ts +++ b/tests/integration/agent/executor-discovery.test.ts @@ -141,6 +141,43 @@ describe("isolated executor local project discovery", () => { } }); + for ( + const [failure, config] of [ + ["invalid setting", 'export default { dev: { port: "synthetic-invalid-port" } };'], + ["invalid syntax", "export default {"], + ] as const + ) { + it(`reports CONFIG_INVALID and releases discovery after config ${failure}`, async () => { + const p = await project(); + const healthy = await project(); + const discovery = owner(p.dir, "writer"); + const next = owner(healthy.dir, "writer"); + try { + await writeFile(join(p.dir, "veryfront.config.ts"), config); + assertEquals(await request(discovery, "discovery.describe"), { + ok: false, + code: "CONFIG_INVALID", + }); + assertEquals(discovery.signal.aborted, true); + await discovery.settled; + assertThrows(() => discovery.getRuntime(), ExecutorDiscoveryError); + assertEquals(existsSync(p.projected), false); + assertEquals(agentRegistry.get("writer"), undefined); + + const recovered = getExecutorDiscoveryResultSchema().parse( + await request(next, "discovery.describe"), + ); + assert(recovered.ok); + assertEquals(recovered.value.defaultAgentId, "writer"); + } finally { + await discovery.close(); + await next.close(); + await p.cleanup(); + await healthy.cleanup(); + } + }); + } + it("preserves valid metadata while keeping collected import errors local", async () => { const p = await project(); await writeFile( From 264ec6bd25f97901b0f1dee5c49a8182e65f4654 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:32:51 +0200 Subject: [PATCH 022/194] fix(agent): redact hosted setup error slugs --- .../hosted/durable-chat-run-start.test.ts | 23 +++++++++++++++++++ src/agent/hosted/durable-chat-run-start.ts | 7 ++++-- src/agent/service/routes.test.ts | 16 +++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/agent/hosted/durable-chat-run-start.test.ts b/src/agent/hosted/durable-chat-run-start.test.ts index 8f092358a5..01dcead87a 100644 --- a/src/agent/hosted/durable-chat-run-start.test.ts +++ b/src/agent/hosted/durable-chat-run-start.test.ts @@ -448,6 +448,29 @@ describe("agent/hosted-durable-chat-run-start", () => { } }); + it("redacts setup error slugs in durable responses and logs", async () => { + const error = PERMISSION_DENIED.create(); + error.slug = "setup?token=synthetic-private-credential"; + const logs: Array | undefined> = []; + const response = await executeHostedDurableChatRun({ + req: createParsedRequest(), + rawRequest: createRequest(), + tracker: createDetachedRunTracker(), + prepareExecution: () => Promise.reject(error), + startDetachedExecution: async () => {}, + logger: { error: (_message, metadata) => logs.push(metadata) }, + }); + + assertEquals(response.status, 403); + assertEquals(await readJson(response), { errorCode: "SETUP?TOKEN=[REDACTED]" }); + assertEquals(logs.length, 1); + assertEquals(logs[0]?.errorCode, "SETUP?TOKEN=[REDACTED]"); + assertEquals( + JSON.stringify(logs).toLowerCase().includes("synthetic-private-credential"), + false, + ); + }); + it("preserves not-supported status for unsupported hosted models", async () => { const response = await executeHostedDurableChatRun({ req: createParsedRequest(), diff --git a/src/agent/hosted/durable-chat-run-start.ts b/src/agent/hosted/durable-chat-run-start.ts index 26eb97a785..2eab40906b 100644 --- a/src/agent/hosted/durable-chat-run-start.ts +++ b/src/agent/hosted/durable-chat-run-start.ts @@ -1,7 +1,10 @@ import { parseProviderError } from "../../chat/provider-errors.ts"; import { INPUT_VALIDATION_FAILED, INVALID_ARGUMENT } from "#veryfront/errors"; import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; -import { sanitizeBoundedDiagnosticText } from "#veryfront/errors/diagnostic-policy.ts"; +import { + sanitizeBoundedDiagnosticText, + sanitizeBoundedErrorSlug, +} from "#veryfront/errors/diagnostic-policy.ts"; import { compactHistoricalUiMessageToolInputs, type HistoricalToolInputCompactionDiagnostic, @@ -107,7 +110,7 @@ export function classifyHostedChatSetupError( const snapshot = snapshotVeryfrontError(error); if (snapshot) { return { - code: snapshot.slug.toUpperCase().replaceAll("-", "_"), + code: sanitizeBoundedErrorSlug(snapshot.slug).toUpperCase().replaceAll("-", "_"), status: snapshot.status, // Error titles can be customized after registration. message: sanitizeBoundedDiagnosticText(snapshot.title), diff --git a/src/agent/service/routes.test.ts b/src/agent/service/routes.test.ts index 0ee17c5c1d..e18dfb6bc4 100644 --- a/src/agent/service/routes.test.ts +++ b/src/agent/service/routes.test.ts @@ -287,6 +287,22 @@ it("agent service routes preserve registered AG-UI setup error titles and codes" assertEquals(output.includes("Synthetic private setup detail"), false); }); +it("agent service routes redact mutable AG-UI setup error slugs", async () => { + const error = PERMISSION_DENIED.create(); + error.slug = "setup?token=synthetic-private-credential"; + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(error), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, 403); + const output = await response.text(); + assertEquals(output.toLowerCase().includes("synthetic-private-credential"), false); + assertStringIncludes(output, '"code":"SETUP?TOKEN=[REDACTED]"'); +}); + it("agent service routes redact mutable AG-UI setup error titles", async () => { const error = PERMISSION_DENIED.create(); const privateValue = "synthetic-private-credential"; From fd09bdb9db42164131cf1a10b43d82a2672f1ef6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:42:49 +0200 Subject: [PATCH 023/194] fix(agent): allow only fixed hosted setup error codes --- src/agent/hosted/durable-chat-run-start.ts | 11 ++++- src/agent/hosted/executor-agent-schema.ts | 53 +++++++++++---------- src/agent/service/routes.test.ts | 55 +++++++++++++++++++++- 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/src/agent/hosted/durable-chat-run-start.ts b/src/agent/hosted/durable-chat-run-start.ts index 26eb97a785..f5c772c6f5 100644 --- a/src/agent/hosted/durable-chat-run-start.ts +++ b/src/agent/hosted/durable-chat-run-start.ts @@ -1,7 +1,9 @@ import { parseProviderError } from "../../chat/provider-errors.ts"; -import { INPUT_VALIDATION_FAILED, INVALID_ARGUMENT } from "#veryfront/errors"; +import { CURATED_PROVIDER_FAILURE_CODES } from "#veryfront/chat/provider-error-registry.ts"; +import { ERROR_REGISTRY, INPUT_VALIDATION_FAILED, INVALID_ARGUMENT } from "#veryfront/errors"; import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; import { sanitizeBoundedDiagnosticText } from "#veryfront/errors/diagnostic-policy.ts"; +import { EXECUTOR_AGENT_FAILURE_CODES } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { compactHistoricalUiMessageToolInputs, type HistoricalToolInputCompactionDiagnostic, @@ -106,8 +108,13 @@ export function classifyHostedChatSetupError( ): { code: string; status?: number; message: string } { const snapshot = snapshotVeryfrontError(error); if (snapshot) { + const code = Object.hasOwn(ERROR_REGISTRY, snapshot.slug) + ? snapshot.slug.toUpperCase().replaceAll("-", "_") + : [...CURATED_PROVIDER_FAILURE_CODES, ...EXECUTOR_AGENT_FAILURE_CODES].find((value) => + value.toLowerCase().replaceAll("_", "-") === snapshot.slug + ) ?? "EXTERNAL_SERVICE_ERROR"; return { - code: snapshot.slug.toUpperCase().replaceAll("-", "_"), + code, status: snapshot.status, // Error titles can be customized after registration. message: sanitizeBoundedDiagnosticText(snapshot.title), diff --git a/src/agent/hosted/executor-agent-schema.ts b/src/agent/hosted/executor-agent-schema.ts index fde4d81dd6..783b1730b5 100644 --- a/src/agent/hosted/executor-agent-schema.ts +++ b/src/agent/hosted/executor-agent-schema.ts @@ -35,32 +35,35 @@ const failureStatus = { ABORTED: 499, } as const; +/** @internal Fixed executor failure codes accepted by hosted response boundaries. */ +export const EXECUTOR_AGENT_FAILURE_CODES = Object.freeze( + [ + "EXECUTOR_AGENT_INVALID_INPUT", + "EXECUTOR_AGENT_INPUT_TOO_LARGE", + "EXECUTOR_AGENT_ALREADY_STARTED", + "EXECUTOR_AGENT_SETUP_FAILED", + "EXECUTOR_AGENT_STREAM_FAILED", + "EXECUTOR_AGENT_INVALID_STREAM", + "OVERLOADED_ERROR", + "CONTEXT_LENGTH_EXCEEDED", + "INSUFFICIENT_CREDITS", + "RESOURCE_LIMIT_EXCEEDED", + "RATE_LIMITED", + "PROJECT_SCHEMA_ERROR", + "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", + "OUTPUT_SCHEMA_NOT_CLOSED", + "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", + "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", + "AI_PROVIDER_BILLING_ERROR", + "EXTERNAL_SERVICE_ERROR", + "PERMISSION_DENIED", + "DURABLE_RUN_EVENT_PERSISTENCE_FAILED", + "ABORTED", + ] as const, +); + export const getExecutorAgentFailureCodeSchema = defineSchema((v) => - v.enum( - [ - "EXECUTOR_AGENT_INVALID_INPUT", - "EXECUTOR_AGENT_INPUT_TOO_LARGE", - "EXECUTOR_AGENT_ALREADY_STARTED", - "EXECUTOR_AGENT_SETUP_FAILED", - "EXECUTOR_AGENT_STREAM_FAILED", - "EXECUTOR_AGENT_INVALID_STREAM", - "OVERLOADED_ERROR", - "CONTEXT_LENGTH_EXCEEDED", - "INSUFFICIENT_CREDITS", - "RESOURCE_LIMIT_EXCEEDED", - "RATE_LIMITED", - "PROJECT_SCHEMA_ERROR", - "MODEL_UNSUPPORTED_ASSISTANT_PREFILL", - "OUTPUT_SCHEMA_NOT_CLOSED", - "AI_PROVIDER_SPEND_LIMIT_EXCEEDED", - "AI_PROVIDER_WORKSPACE_LIMIT_EXCEEDED", - "AI_PROVIDER_BILLING_ERROR", - "EXTERNAL_SERVICE_ERROR", - "PERMISSION_DENIED", - "DURABLE_RUN_EVENT_PERSISTENCE_FAILED", - "ABORTED", - ] as const, - ) + v.enum(EXECUTOR_AGENT_FAILURE_CODES) ); type FailureCode = InferSchema>; diff --git a/src/agent/service/routes.test.ts b/src/agent/service/routes.test.ts index 0ee17c5c1d..9c1518d3ac 100644 --- a/src/agent/service/routes.test.ts +++ b/src/agent/service/routes.test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; -import { PERMISSION_DENIED } from "#veryfront/errors"; +import { PERMISSION_DENIED, VeryfrontError } from "#veryfront/errors"; import { createDetachedRunTracker } from "./detached-run-tracker.ts"; import { createHostedAgentServiceRouteSet } from "./routes.ts"; import { type HostedServiceAuthenticatedRequest, HostedServiceAuthError } from "./auth.ts"; @@ -305,6 +305,59 @@ it("agent service routes redact mutable AG-UI setup error titles", async () => { assertStringIncludes(output, '"code":"PERMISSION_DENIED"'); }); +for ( + const [kind, slug] of [ + ["credentials", "Bearer synthetic-private-credential"], + ["host", "https://private-service.example.test/failure"], + ["path", "/synthetic-private-project/workspace/failure"], + ["custom identifier", "synthetic-private-identifier"], + ["oversized identifier", "synthetic".repeat(100)], + ] as const +) { + it(`agent service routes suppress ${kind} in setup error slugs`, async () => { + const error = PERMISSION_DENIED.create(); + error.slug = slug; + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(error), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, 403); + const output = await response.text(); + assertStringIncludes(output, '"code":"EXTERNAL_SERVICE_ERROR"'); + assertStringIncludes(output, '"message":"File/resource permission denied"'); + }); +} + +it("agent service routes preserve curated model setup error codes", async () => { + for ( + const [slug, code, status] of [ + ["rate-limited", "RATE_LIMITED", 429], + ["overloaded-error", "OVERLOADED_ERROR", 503], + ["context-length-exceeded", "CONTEXT_LENGTH_EXCEEDED", 413], + ["ai-provider-billing-error", "AI_PROVIDER_BILLING_ERROR", 502], + ] as const + ) { + const error = new VeryfrontError("Synthetic model failure", { + slug, + category: "AGENT", + status, + title: "Synthetic model failure", + }); + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(error), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + + assertEquals(response.status, status); + assertStringIncludes(await response.text(), `"code":"${code}"`); + } +}); + Deno.test("agent service routes ignore client-controlled AG-UI target agent ids", async () => { const { routeSet, preparedRequests } = createRouteSet(); const response = await routeSet.handleAgUiRequest( From 617fdaf02a7d71561ce41221c980cb3bcb598d3b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 22:46:48 +0200 Subject: [PATCH 024/194] test(agent): cover discovery replies and capacity cleanup --- src/agent/hosted/executor-discovery.test.ts | 98 +++++++++++++++++---- 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index 47fdd2df1e..823e5aefe1 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -1,14 +1,19 @@ 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 { CONFIG_INVALID } from "#veryfront/errors"; +import { CONFIG_INVALID, CONFIG_PARSE_ERROR, CONFIG_VALIDATION_FAILED } from "#veryfront/errors"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { agent } from "../factory.ts"; import type { Agent } from "../types.ts"; import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; import { createRuntimeAgentFromMarkdownDefinition } from "../runtime/agent-markdown-adapter.ts"; import { createExecutorDiscovery, type ExecutorDiscoveryBackend } from "./executor-discovery.ts"; -import { ExecutorDiscoveryError } from "./executor-discovery-schema.ts"; +import { + EXECUTOR_DISCOVERY_MAX_AGENTS, + ExecutorDiscoveryError, + getExecutorAgentDescribeResultSchema, + getExecutorDiscoveryResultSchema, +} from "./executor-discovery-schema.ts"; const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; const source = { type: "release", releaseId: "synthetic-release" } as const; @@ -98,6 +103,7 @@ describe("executor discovery operations", () => { assertEquals([...f.owner.operations.keys()], ["discovery.describe", "agent.describe"]); assertThrows(() => f.owner.getRuntime(), ExecutorDiscoveryError); const result = await call(f.owner, "discovery.describe"); + assert(getExecutorDiscoveryResultSchema().parse(result).ok); assertEquals(result, { ok: true, value: { @@ -353,22 +359,82 @@ describe("executor discovery operations", () => { assertEquals(cleaned, 1); }); - it("cleans partial discovery even when the loader throws a registered configuration error", async () => { - let cleaned = 0; - const f = fixture({ - backend: { - load: () => - Promise.reject(CONFIG_INVALID.create({ detail: "synthetic-private-diagnostic" })), - cleanup: () => { - cleaned++; - return Promise.resolve(); + for (const failure of [CONFIG_INVALID, CONFIG_VALIDATION_FAILED, CONFIG_PARSE_ERROR]) { + it(`cleans partial discovery when the loader throws ${failure.slug}`, async () => { + let cleaned = 0; + const f = fixture({ + backend: { + load: () => Promise.reject(failure.create({ detail: "synthetic-private-diagnostic" })), + cleanup: (state) => { + assertEquals(state, undefined); + cleaned++; + return Promise.resolve(); + }, }, - }, + }); + assertEquals( + getExecutorDiscoveryResultSchema().parse(await call(f.owner, "discovery.describe")), + { ok: false, code: "CONFIG_INVALID" }, + ); + await f.owner.settled; + assertEquals(cleaned, 1); + assertEquals(f.owner.signal.aborted, true); + await f.owner.close(); + assertEquals(cleaned, 1); }); - assertEquals(await call(f.owner, "discovery.describe"), { ok: false, code: "CONFIG_INVALID" }); - assertEquals(cleaned, 1); - assertEquals(f.owner.signal.aborted, true); - await f.owner.close(); + } + + it("rejects uncached agents before projection at capacity and retains cached descriptions", async () => { + let projections = 0; + const f = fixture({ + agentSource: "code", + agents: Array.from({ length: EXECUTOR_DISCOVERY_MAX_AGENTS }, (_, index) => + agent({ + id: `agent-${index}`, + system: () => { + projections++; + return `Synthetic instructions ${index}`; + }, + })), + }); + try { + for (let index = 0; index < EXECUTOR_DISCOVERY_MAX_AGENTS; index++) { + const result = getExecutorAgentDescribeResultSchema().parse( + await call(f.owner, "agent.describe", { agentId: `agent-${index}` }), + ); + assert(result.ok); + assertEquals(result.value.definition.instructions, `Synthetic instructions ${index}`); + } + f.state.agents.set( + "uncached", + agent({ + id: "uncached", + system: () => { + projections++; + return "Uncached instructions"; + }, + }), + ); + assertEquals( + getExecutorAgentDescribeResultSchema().parse( + await call(f.owner, "agent.describe", { agentId: "uncached" }), + ), + { ok: false, code: "EXECUTOR_DISCOVERY_BUSY" }, + ); + const cached = getExecutorAgentDescribeResultSchema().parse( + await call(f.owner, "agent.describe", { agentId: "agent-0" }), + ); + assert(cached.ok); + assertEquals(cached.value.definition.instructions, "Synthetic instructions 0"); + assertEquals(projections, EXECUTOR_DISCOVERY_MAX_AGENTS); + assertEquals(f.owner.getRuntime(), f.state); + assertEquals(f.owner.signal.aborted, false); + assertEquals(f.loads, 1); + assertEquals(f.cleanups, 0); + } finally { + await f.owner.close(); + } + assertEquals(f.cleanups, 1); }); it("reports cleanup failure without exposing its diagnostic", async () => { From dc2321bc5817390826ad02aecb698f93f799aa8b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 23:07:49 +0200 Subject: [PATCH 025/194] fix(agent): bound AG-UI setup response statuses --- src/agent/service/routes.test.ts | 26 ++++++++++++++++++++++++++ src/agent/service/routes.ts | 10 ++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/agent/service/routes.test.ts b/src/agent/service/routes.test.ts index 69369e1c07..f5a4af4d96 100644 --- a/src/agent/service/routes.test.ts +++ b/src/agent/service/routes.test.ts @@ -287,6 +287,32 @@ it("agent service routes preserve registered AG-UI setup error titles and codes" assertEquals(output.includes("Synthetic private setup detail"), false); }); +it("agent service routes reject non-error and invalid custom setup statuses", async () => { + for (const status of [-1, 200, 204, 302, 399, 403.5, 600, 700]) { + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(PERMISSION_DENIED.create({ status })), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + assertEquals(response.status, 500); + assertStringIncludes(await response.text(), '"code":"PERMISSION_DENIED"'); + } +}); + +it("agent service routes preserve valid custom setup error statuses", async () => { + for (const status of [400, 422, 499, 503, 599]) { + const { routeSet } = createRouteSet({ + prepareExecution: () => Promise.reject(PERMISSION_DENIED.create({ status })), + }); + const response = await routeSet.handleAgUiRequest( + createAuthenticatedRequest("/api/ag-ui", createAgUiBody()), + ); + assertEquals(response.status, status); + assertStringIncludes(await response.text(), '"code":"PERMISSION_DENIED"'); + } +}); + it("agent service routes redact mutable AG-UI setup error slugs", async () => { const error = PERMISSION_DENIED.create(); error.slug = "setup?token=synthetic-private-credential"; diff --git a/src/agent/service/routes.ts b/src/agent/service/routes.ts index 35289cbd22..ea1a4d7132 100644 --- a/src/agent/service/routes.ts +++ b/src/agent/service/routes.ts @@ -266,8 +266,14 @@ function createAgUiSetupErrorResponse(input: { runId: input.runId, }); - const statusCode = status || - (code === "OVERLOADED_ERROR" ? 503 : code === "CONTEXT_LENGTH_EXCEEDED" ? 413 : 500); + const statusCode = status !== undefined && Number.isInteger(status) && status >= 400 && + status <= 599 + ? status + : code === "OVERLOADED_ERROR" + ? 503 + : code === "CONTEXT_LENGTH_EXCEEDED" + ? 413 + : 500; return createAgUiSseErrorResponse(createAgUiRunErrorEvent(message, code), statusCode); } From 7a1d19bdb6689934114e34bb1c88f019bcb83d3f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 23:20:49 +0200 Subject: [PATCH 026/194] fix(agent): frame executor streams with incremental byte accounting --- .../streaming/executor-data-stream.test.ts | 35 ++++++++++++ src/agent/streaming/executor-data-stream.ts | 57 +++++++++++-------- 2 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/agent/streaming/executor-data-stream.test.ts b/src/agent/streaming/executor-data-stream.test.ts index 1bc8acd165..0350fed5fe 100644 --- a/src/agent/streaming/executor-data-stream.test.ts +++ b/src/agent/streaming/executor-data-stream.test.ts @@ -23,6 +23,24 @@ async function collect(stream: ReadableStream) { } describe("executor runtime data stream validation", () => { + it("accepts an exact-limit event with its separator split across chunks", async () => { + const prefix = 'data: {"type":"text-delta","delta":"'; + const suffix = '"}'; + const delta = "x".repeat(EXECUTOR_AGENT_MAX_PAYLOAD_BYTES - prefix.length - suffix.length); + const body = new TextEncoder().encode(prefix + delta + suffix); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.enqueue(new Uint8Array([10])); + controller.enqueue(new TextEncoder().encode('\ndata: {"type":"message-finish"}\n\n')); + controller.close(); + }, + }); + assertEquals(await collect(stream), [{ type: "text-delta", delta }, { + type: "message-finish", + }]); + }); + it("reassembles split UTF-8 and coalesced events without changing text", async () => { const events: JsonValue[] = [{ type: "text-delta", delta: "Synthetic å🙂\ntext" }, { type: "message-finish", @@ -40,6 +58,23 @@ describe("executor runtime data stream validation", () => { assertEquals(await collect(stream), events); }); + it("reads a large event one byte at a time across buffer growth and UTF-8 boundaries", async () => { + const events: JsonValue[] = [{ type: "text-delta", delta: "x".repeat(32_768) + "å🙂" }, { + type: "message-finish", + }]; + const bytes = new TextEncoder().encode( + "\uFEFF" + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), + ); + let offset = 0; + const stream = new ReadableStream({ + pull(controller) { + if (offset === bytes.length) controller.close(); + else controller.enqueue(bytes.subarray(offset, ++offset)); + }, + }); + assertEquals(await collect(stream), events); + }); + for ( const bytes of [ new Uint8Array([0xff]), diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index a62fd9d7a0..9228c85596 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -5,7 +5,15 @@ import { } from "../hosted/executor-agent-schema.ts"; import { parseExecutorDataEvent } from "./executor-data-schema.ts"; -const textEncoder = new TextEncoder(); +function parseBlock(block: string) { + const lines = block.split("\n"); + if (!lines.length || lines.some((line) => !line.startsWith("data:"))) { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + return parseExecutorDataEvent( + JSON.parse(lines.map((line) => line.slice(5).trimStart()).join("\n")), + ); +} /** @internal Strict bounded SSE reader for the executor's runtime stream. */ export async function* readExecutorDataEvents( @@ -13,8 +21,10 @@ export async function* readExecutorDataEvents( signal: AbortSignal, ) { const reader = stream.getReader(); + const validator = new TextDecoder("utf-8", { fatal: true }); const decoder = new TextDecoder("utf-8", { fatal: true }); - let pending = ""; + let pending = new Uint8Array(4096); + let pendingBytes = 0; let terminal = false; let completed = false; // Only the first cancel promise includes asynchronous source cleanup. @@ -38,34 +48,35 @@ export async function* readExecutorDataEvents( ) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } - // Process bounded slices even when the local producer coalesces many events. + // Validate UTF-8 incrementally, including malformed streams that never end. + // Frame raw bytes once; small chunks never rescan or re-encode a prefix. for (let offset = 0; offset < next.value.byteLength; offset += 4096) { - pending += decoder.decode(next.value.subarray(offset, offset + 4096), { stream: true }); - let separator: number; - while ((separator = pending.indexOf("\n\n")) !== -1) { - const block = pending.slice(0, separator); - pending = pending.slice(separator + 2); - if (textEncoder.encode(block).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { - throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + const fragment = next.value.subarray(offset, offset + 4096); + validator.decode(fragment, { stream: true }); + for (const byte of fragment) { + if (pendingBytes === pending.length) { + const grown = new Uint8Array( + Math.min(pending.length * 2, EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + 2), + ); + grown.set(pending); + pending = grown; } - const lines = block.split("\n"); - if (!lines.length || lines.some((line) => !line.startsWith("data:"))) { + pending[pendingBytes++] = byte; + if (byte === 10 && pendingBytes >= 2 && pending[pendingBytes - 2] === 10) { + const block = decoder.decode(pending.subarray(0, pendingBytes), { stream: true }); + pendingBytes = 0; + const event = parseBlock(block.slice(0, -2)); + terminal ||= event.type === "message-finish" || event.type === "finish" || + event.type === "error"; + yield event; + } else if (pendingBytes > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + (byte === 10 ? 1 : 0)) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } - const event = parseExecutorDataEvent( - JSON.parse(lines.map((line) => line.slice(5).trimStart()).join("\n")), - ); - terminal ||= event.type === "message-finish" || event.type === "finish" || - event.type === "error"; - yield event; - } - if (textEncoder.encode(pending).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) { - throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } } } - pending += decoder.decode(); - if (pending.length > 0 || !terminal) { + validator.decode(); + if (pendingBytes > 0 || !terminal) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } completed = true; From 41195054bc9bfe9baf630631e681c7c99a72b709 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 23:37:50 +0200 Subject: [PATCH 027/194] fix(agent): require whole-message executor completion --- src/agent/hosted/executor-agent-bridge.test.ts | 14 +++++++++++++- src/agent/hosted/executor-agent-bridge.ts | 3 +-- src/agent/streaming/executor-data-stream.test.ts | 10 ++++++++++ src/agent/streaming/executor-data-stream.ts | 3 +-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/agent/hosted/executor-agent-bridge.test.ts b/src/agent/hosted/executor-agent-bridge.test.ts index cf7c719880..ead794582b 100644 --- a/src/agent/hosted/executor-agent-bridge.test.ts +++ b/src/agent/hosted/executor-agent-bridge.test.ts @@ -237,6 +237,7 @@ describe("executor hosted agent bridge", () => { const input of [ 'data: {"type":"text-delta","delta":42}\n\n', 'data: {"type":"text-delta","delta":"unfinished"}\n\n', + 'data: {"type":"finish","finishReason":"tool-calls"}\n\n', 'data: {"type":"message-finish"}', "data: not-json\n\n", ] @@ -275,6 +276,10 @@ describe("executor hosted agent bridge", () => { const invalidFrames: JsonValue[][] = [ [{ type: "ready" }], + [{ type: "ready" }, { + type: "event", + event: { type: "finish", finishReason: "tool-calls" }, + }, { type: "complete" }], [{ type: "ready" }, { type: "complete" }, { type: "event", event: { type: "message-finish" }, @@ -300,7 +305,14 @@ describe("executor hosted agent bridge", () => { channel: channels.broker, preparedRuntimeHandle: handle, }).stream({ messages, abortSignal: new AbortController().signal }); - await assertRejects(() => collect(runtime.toUIMessageStream()), ExecutorAgentError); + let finished = false; + await assertRejects(() => + collect(runtime.toUIMessageStream({ + onFinish: () => { + finished = true; + }, + })), ExecutorAgentError); + assertEquals(finished, false); } finally { await channels.close(); } diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index f5f79ee68b..0225fc8c24 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -170,8 +170,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { const frame = parseFrame(next.value); if (frame.type === "event") { const event = parseExecutorDataEvent(frame.event); - terminal ||= event.type === "message-finish" || event.type === "finish" || - event.type === "error"; + terminal ||= event.type === "message-finish" || event.type === "error"; controller.enqueue( textEncoder.encode(`data: ${JSON.stringify(event)}\n\n`), ); diff --git a/src/agent/streaming/executor-data-stream.test.ts b/src/agent/streaming/executor-data-stream.test.ts index 0350fed5fe..bc47653a05 100644 --- a/src/agent/streaming/executor-data-stream.test.ts +++ b/src/agent/streaming/executor-data-stream.test.ts @@ -23,6 +23,16 @@ async function collect(stream: ReadableStream) { } describe("executor runtime data stream validation", () => { + it("requires whole-message completion after a provider finishes its step", async () => { + const step = 'data: {"type":"finish","finishReason":"tool-calls"}\n\n'; + await assertRejects(() => collect(new Response(step).body!), ExecutorAgentError); + const complete = step + 'data: {"type":"message-finish"}\n\n'; + assertEquals(await collect(new Response(complete).body!), [ + { type: "finish", finishReason: "tool-calls" }, + { type: "message-finish" }, + ]); + }); + it("accepts an exact-limit event with its separator split across chunks", async () => { const prefix = 'data: {"type":"text-delta","delta":"'; const suffix = '"}'; diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index 9228c85596..cc92d8afb5 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -66,8 +66,7 @@ export async function* readExecutorDataEvents( const block = decoder.decode(pending.subarray(0, pendingBytes), { stream: true }); pendingBytes = 0; const event = parseBlock(block.slice(0, -2)); - terminal ||= event.type === "message-finish" || event.type === "finish" || - event.type === "error"; + terminal ||= event.type === "message-finish" || event.type === "error"; yield event; } else if (pendingBytes > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + (byte === 10 ? 1 : 0)) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); From b86d594f1b2bef1f7f39a3c0883499c7dfabde8c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 23:39:22 +0200 Subject: [PATCH 028/194] fix(agent): audit effective OpenAI tool reasoning --- .../model-call-context-request.test.ts | 90 +++++++++++++++++++ src/runtime/model-call-context-request.ts | 15 +++- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/runtime/model-call-context-request.test.ts diff --git a/src/runtime/model-call-context-request.test.ts b/src/runtime/model-call-context-request.test.ts new file mode 100644 index 0000000000..dbdd063028 --- /dev/null +++ b/src/runtime/model-call-context-request.test.ts @@ -0,0 +1,90 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { createWarningCollector } from "#veryfront/provider/shared/index.ts"; +import { + resolveVeryfrontCloudOpenAIChatFunctionToolReasoning, + resolveVeryfrontCloudOpenAITransport, +} from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; +import { buildModelCallContextRequest } from "#veryfront/runtime/model-call-context-request.ts"; +import { buildOpenAIChatRequest } from "../../extensions/ext-llm-openai/src/openai-chat-request-builder.ts"; + +const prompt: ModelRuntimeCallOptions["prompt"] = [{ + role: "user", + content: [{ type: "text", text: "Synthetic request" }], +}]; +const tools: ModelRuntimeCallOptions["tools"] = [{ + type: "function", + name: "lookup", + inputSchema: { type: "object", properties: {} }, +}]; + +describe("model call request projection", () => { + for (const modelId of ["gpt-5.4", "gpt-5.5"]) { + it(`matches ${modelId} Cloud Chat reasoning with and without function tools`, () => { + const catalogId = `openai/${modelId}`; + assertEquals(resolveVeryfrontCloudOpenAITransport(catalogId), "chat-completions"); + const capabilities = { + reasoningWithFunctionTools: resolveVeryfrontCloudOpenAIChatFunctionToolReasoning(catalogId), + }; + for ( + const [reasoning, expectedEffort] of [ + [undefined, "medium"], + [{ enabled: true, effort: "max" }, "high"], + [{ enabled: false }, undefined], + ] as const + ) { + for (const useTools of [false, true]) { + const options: ModelRuntimeCallOptions = { + prompt, + reasoning, + ...(useTools ? { tools } : {}), + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId, + }, options); + const effort = useTools ? undefined : expectedEffort; + assertEquals(projected?.reasoning, { + enabled: effort !== undefined, + ...(effort !== undefined ? { effort } : {}), + }); + for (const stream of [false, true]) { + const body = buildOpenAIChatRequest( + modelId, + "veryfront-cloud", + options, + stream, + createWarningCollector(), + capabilities, + ); + assertEquals(body.reasoning_effort, effort); + assertEquals(projected?.reasoning?.effort, body.reasoning_effort); + assertEquals(projected?.reasoning?.enabled, body.reasoning_effort !== undefined); + assertEquals(body.tools?.[0]?.function.name, useTools ? "lookup" : undefined); + } + } + } + }); + } + + it("retains reasoning for direct OpenAI and Cloud models without the Chat restriction", () => { + for ( + const [provider, modelId] of [ + ["openai", "gpt-5.4"], + ["openai", "gpt-5.5"], + ["veryfront-cloud", "gpt-5.4-nano"], + ["veryfront-cloud", "o3"], + ] + ) { + assertEquals( + buildModelCallContextRequest({ provider, modelProvider: "openai", modelId }, { + tools, + reasoning: { enabled: true, effort: "max" }, + })?.reasoning, + { enabled: true, effort: "high" }, + ); + } + }); +}); diff --git a/src/runtime/model-call-context-request.ts b/src/runtime/model-call-context-request.ts index e9a3f963ee..3e072492e0 100644 --- a/src/runtime/model-call-context-request.ts +++ b/src/runtime/model-call-context-request.ts @@ -4,10 +4,14 @@ import type { RuntimeReasoningOption, } from "#veryfront/provider/types.ts"; import { resolveOpenAIReasoningConfig } from "#veryfront/provider/shared/openai-reasoning.ts"; +import { + resolveVeryfrontCloudOpenAIChatFunctionToolReasoning, + resolveVeryfrontCloudOpenAITransport, +} from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; import type { ModelCallRequest } from "./model-call-context.ts"; type ModelCallRuntimeMetadata = Pick; -type ModelCallRequestSource = Pick & { +type ModelCallRequestSource = Pick & { providerOptions?: unknown; }; @@ -84,6 +88,15 @@ function resolvePersistedReasoning( ): RuntimeReasoningOption | undefined { const modelProvider = resolveModelCallProvider(model); if (modelProvider === "openai" && typeof model.modelId === "string") { + const catalogId = `openai/${model.modelId}`; + if ( + model.provider === "veryfront-cloud" && + resolveVeryfrontCloudOpenAITransport(catalogId) === "chat-completions" && + resolveVeryfrontCloudOpenAIChatFunctionToolReasoning(catalogId) === false && + options.tools?.some((tool) => tool.type === "function") + ) { + return { enabled: false }; + } const reasoning = resolveOpenAIReasoningConfig(model.modelId, modelProvider, options.reasoning); return reasoning ? { enabled: true, effort: reasoning.effort } : options.reasoning; } From 554f7ed7295131c51bf68a48e91666ea68499675 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 23:56:07 +0200 Subject: [PATCH 029/194] fix(agent): audit reasoning for effective provider tools --- .../model-call-context-request.test.ts | 72 +++++++++++++++++++ src/runtime/model-call-context-request.ts | 21 +++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/runtime/model-call-context-request.test.ts b/src/runtime/model-call-context-request.test.ts index dbdd063028..da7c981efa 100644 --- a/src/runtime/model-call-context-request.test.ts +++ b/src/runtime/model-call-context-request.test.ts @@ -18,6 +18,10 @@ const tools: ModelRuntimeCallOptions["tools"] = [{ name: "lookup", inputSchema: { type: "object", properties: {} }, }]; +const nativeTools = [{ + type: "function", + function: { name: "lookup", parameters: { type: "object", properties: {} } }, +}]; describe("model call request projection", () => { for (const modelId of ["gpt-5.4", "gpt-5.5"]) { @@ -67,6 +71,74 @@ describe("model call request projection", () => { } } }); + + for ( + const { name, options: toolOptions, hasFunctionTools } of [ + { + name: "adds native function tools", + options: { providerOptions: { "veryfront-cloud": { tools: nativeTools } } }, + hasFunctionTools: true, + }, + { + name: "clears neutral tools with an empty native list", + options: { tools, providerOptions: { "veryfront-cloud": { tools: [] } } }, + hasFunctionTools: false, + }, + { + name: "clears neutral tools with an explicit undefined native list", + options: { tools, providerOptions: { "veryfront-cloud": { tools: undefined } } }, + hasFunctionTools: false, + }, + { + name: "uses native tools from the OpenAI bucket", + options: { providerOptions: { openai: { tools: nativeTools } } }, + hasFunctionTools: true, + }, + { + name: "lets the Cloud bucket clear OpenAI native tools", + options: { + providerOptions: { openai: { tools: nativeTools }, "veryfront-cloud": { tools: [] } }, + }, + hasFunctionTools: false, + }, + ] + ) { + it(`matches ${modelId} reasoning when the request ${name}`, () => { + for ( + const [reasoning, expectedEffort] of [ + [undefined, "medium"], + [{ enabled: true, effort: "max" }, "high"], + [{ enabled: false }, undefined], + ] as const + ) { + const options: ModelRuntimeCallOptions = { prompt, reasoning, ...toolOptions }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId, + }, options); + const effort = hasFunctionTools ? undefined : expectedEffort; + for (const stream of [false, true]) { + const body = buildOpenAIChatRequest( + modelId, + "veryfront-cloud", + options, + stream, + createWarningCollector(), + { + reasoningWithFunctionTools: resolveVeryfrontCloudOpenAIChatFunctionToolReasoning( + `openai/${modelId}`, + ), + }, + ); + assertEquals(body.reasoning_effort, effort); + assertEquals(body.tools?.[0]?.function.name, hasFunctionTools ? "lookup" : undefined); + assertEquals(projected?.reasoning?.effort, body.reasoning_effort); + assertEquals(projected?.reasoning?.enabled, body.reasoning_effort !== undefined); + } + } + }); + } } it("retains reasoning for direct OpenAI and Cloud models without the Chat restriction", () => { diff --git a/src/runtime/model-call-context-request.ts b/src/runtime/model-call-context-request.ts index 3e072492e0..25edbf51ea 100644 --- a/src/runtime/model-call-context-request.ts +++ b/src/runtime/model-call-context-request.ts @@ -4,6 +4,7 @@ import type { RuntimeReasoningOption, } from "#veryfront/provider/types.ts"; import { resolveOpenAIReasoningConfig } from "#veryfront/provider/shared/openai-reasoning.ts"; +import { readProviderOptions } from "#veryfront/provider/runtime-loader.ts"; import { resolveVeryfrontCloudOpenAIChatFunctionToolReasoning, resolveVeryfrontCloudOpenAITransport, @@ -92,10 +93,24 @@ function resolvePersistedReasoning( if ( model.provider === "veryfront-cloud" && resolveVeryfrontCloudOpenAITransport(catalogId) === "chat-completions" && - resolveVeryfrontCloudOpenAIChatFunctionToolReasoning(catalogId) === false && - options.tools?.some((tool) => tool.type === "function") + resolveVeryfrontCloudOpenAIChatFunctionToolReasoning(catalogId) === false ) { - return { enabled: false }; + // Match the Chat builder's native bucket precedence, including an own + // tools value that clears the neutral list with [] or undefined. + const providerOptions = readProviderOptions( + options.providerOptions as Record | undefined, + "openai", + "veryfront-cloud", + ); + const tools = ObjectHasOwn(providerOptions, "tools") ? providerOptions.tools : options.tools; + if ( + Array.isArray(tools) && + tools.some((tool) => + tool !== null && typeof tool === "object" && "type" in tool && tool.type === "function" + ) + ) { + return { enabled: false }; + } } const reasoning = resolveOpenAIReasoningConfig(model.modelId, modelProvider, options.reasoning); return reasoning ? { enabled: true, effort: reasoning.effort } : options.reasoning; From f227ce480afebf7ecc15bc7cccda19e6bfc86a7c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 7 Sep 2026 23:57:45 +0200 Subject: [PATCH 030/194] fix(agent): bind discovery roots to the immutable source --- src/agent/hosted/executor-discovery-node.ts | 8 +- .../hosted/executor-discovery-roots.test.ts | 80 +++++++++++++++++++ src/agent/hosted/executor-discovery-roots.ts | 49 ++++++++++++ .../agent/executor-discovery.test.ts | 55 ++++++++++++- 4 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/agent/hosted/executor-discovery-roots.test.ts create mode 100644 src/agent/hosted/executor-discovery-roots.ts diff --git a/src/agent/hosted/executor-discovery-node.ts b/src/agent/hosted/executor-discovery-node.ts index 3373de3db7..038f0d3b87 100644 --- a/src/agent/hosted/executor-discovery-node.ts +++ b/src/agent/hosted/executor-discovery-node.ts @@ -1,4 +1,6 @@ +import { realpath } from "node:fs/promises"; import { clearConfigCache, getConfig } from "#veryfront/config"; +import { bindExecutorDiscoveryRoots } from "#veryfront/agent/hosted/executor-discovery-roots.ts"; import { clearRegistryScope } from "#veryfront/registry/project-scoped-registry-manager.ts"; import { tryGetRegistryScopeId } from "#veryfront/cache/cache-key-builder.ts"; import { clearTranspileCache } from "#veryfront/discovery/transpiler.ts"; @@ -28,14 +30,16 @@ export function createNodeExecutorDiscoveryBackend( activeOwner = owner; claimed = true; const config = await getConfig(input.projectDir, nodeAdapter, { cacheKey: input.cacheKey }); + const projectDir = await realpath(input.projectDir); + const boundConfig = await bindExecutorDiscoveryRoots(projectDir, config, realpath); signal.throwIfAborted(); return discoverProjectAgentRuntime({ - projectDir: input.projectDir, + projectDir, cacheKey: input.cacheKey, adapter: nodeAdapter, // Application storage configuration does not select the executor's // source filesystem. Preserve discovery paths/policy in a local copy. - config: { ...config, fs: { type: "local" } }, + config: boundConfig, allowHostProjectCodeExecution: true, }); }, diff --git a/src/agent/hosted/executor-discovery-roots.test.ts b/src/agent/hosted/executor-discovery-roots.test.ts new file mode 100644 index 0000000000..181d9304f3 --- /dev/null +++ b/src/agent/hosted/executor-discovery-roots.test.ts @@ -0,0 +1,80 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { VeryfrontConfig } from "#veryfront/config"; +import { bindExecutorDiscoveryRoots } from "#veryfront/agent/hosted/executor-discovery-roots.ts"; +import { ExecutorDiscoveryError } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; + +const kinds = [ + "tools", + "agents", + "skills", + "resources", + "prompts", + "workflows", + "tasks", + "schedules", + "webhooks", + "evals", +] as const; + +describe("executor discovery root policy", () => { + it("binds default roots inside the project and preserves the input configuration", async () => { + const config: VeryfrontConfig = {}; + const before = structuredClone(config); + const result = await bindExecutorDiscoveryRoots( + "/bound", + config, + (path) => Promise.resolve(path), + ); + for (const kind of kinds) assertEquals(result.ai[kind]?.discovery?.paths, [kind]); + assertEquals(result.fs?.type, "local"); + assertEquals(config, before); + }); + + for (const kind of kinds) { + it(`rejects an outside canonical ${kind} root`, async () => { + const error = await assertRejects( + () => + bindExecutorDiscoveryRoots( + "/bound", + {}, + (path) => Promise.resolve(path === `/bound/${kind}` ? "/outside/source" : path), + ), + ExecutorDiscoveryError, + ); + assert(error instanceof ExecutorDiscoveryError); + assertEquals(error.code, "CONFIG_INVALID"); + }); + } + + it("uses canonical relative paths for inside aliases and omits missing or disabled roots", async () => { + const config: VeryfrontConfig = { + ai: { agents: { discovery: { paths: ["alias"] } }, tools: { discovery: { enabled: false } } }, + }; + const visited: string[] = []; + const result = await bindExecutorDiscoveryRoots("/bound", config, (path) => { + visited.push(path); + if (path === "/bound/alias") return Promise.resolve("/bound/source/agents"); + return Promise.reject(Object.assign(new Error("Missing synthetic root"), { code: "ENOENT" })); + }); + assertEquals(result.ai.agents?.discovery?.paths, ["source/agents"]); + assertEquals(result.ai.tools?.discovery?.enabled, false); + assertEquals(result.ai.tools?.discovery?.paths, []); + assertEquals(result.ai.skills?.discovery?.paths, []); + assertEquals(visited.includes("/bound/tools"), false); + }); + + it("does not silently ignore canonicalization failures other than missing roots", async () => { + await assertRejects( + () => + bindExecutorDiscoveryRoots( + "/bound", + {}, + () => Promise.reject(new Error("Synthetic permission failure")), + ), + Error, + "Synthetic permission failure", + ); + }); +}); diff --git a/src/agent/hosted/executor-discovery-roots.ts b/src/agent/hosted/executor-discovery-roots.ts new file mode 100644 index 0000000000..7707fe5638 --- /dev/null +++ b/src/agent/hosted/executor-discovery-roots.ts @@ -0,0 +1,49 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { VeryfrontConfig } from "#veryfront/config"; +import { createProjectDiscoveryConfig } from "#veryfront/discovery/project-discovery-config.ts"; +import { ExecutorDiscoveryError } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; + +/** @internal Bind every enabled discovery root to the canonical immutable source. */ +export async function bindExecutorDiscoveryRoots( + projectDir: string, + config: VeryfrontConfig, + canonicalize: (path: string) => Promise, +) { + const localConfig: VeryfrontConfig = { ...config, fs: { type: "local" } }; + const discovery = createProjectDiscoveryConfig({ projectDir, config: localConfig }); + const ai = { ...localConfig.ai }; + for ( + const [kind, directories] of [ + ["tools", discovery.toolDirs], + ["agents", discovery.agentDirs], + ["skills", discovery.skillDirs], + ["resources", discovery.resourceDirs], + ["prompts", discovery.promptDirs], + ["workflows", discovery.workflowDirs], + ["tasks", discovery.taskDirs], + ["schedules", discovery.scheduleDirs], + ["webhooks", discovery.webhookDirs], + ["evals", discovery.evalDirs], + ] as const + ) { + const paths: string[] = []; + for (const directory of directories) { + let canonical: string; + try { + canonical = await canonicalize(resolve(projectDir, directory)); + } catch (error) { + if ( + error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT" + ) continue; + throw error; + } + const local = relative(projectDir, canonical); + if (local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new ExecutorDiscoveryError("CONFIG_INVALID"); + } + paths.push(local || "."); + } + ai[kind] = { ...ai[kind], discovery: { ...ai[kind]?.discovery, paths } }; + } + return { ...localConfig, ai }; +} diff --git a/tests/integration/agent/executor-discovery.test.ts b/tests/integration/agent/executor-discovery.test.ts index ffe55145a0..4eaf20ae44 100644 --- a/tests/integration/agent/executor-discovery.test.ts +++ b/tests/integration/agent/executor-discovery.test.ts @@ -2,7 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import "#veryfront/skill/_test-setup.ts"; import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -80,6 +80,59 @@ async function request(discovery: ReturnType, name: string, value: } describe("isolated executor local project discovery", () => { + for (const root of ["agents", "crew", "tools"]) { + it(`rejects an outside symlink used as the ${root} discovery root before loading modules`, async () => { + const p = await project(); + const outside = await mkdtemp(join(tmpdir(), "vf-executor-outside-root-")); + const marker = join(outside, "loaded"); + const discovery = owner(p.dir, "writer"); + try { + if (root === "agents") { + await writeFile(join(p.dir, "veryfront.config.ts"), "export default {};"); + } + if (root === "crew") await rm(join(p.dir, "crew"), { recursive: true }); + await writeFile( + join(outside, "writer.md"), + "---\nname: Outside\n---\n\nOutside metadata.\n", + ); + await writeFile( + join(outside, "load.ts"), + `import { writeFileSync } from "node:fs"; writeFileSync(${ + JSON.stringify(marker) + }, "loaded"); export default {};`, + ); + await symlink(outside, join(p.dir, root)); + assertEquals(await request(discovery, "discovery.describe"), { + ok: false, + code: "CONFIG_INVALID", + }); + assertEquals(existsSync(marker), false); + await discovery.settled; + } finally { + await discovery.close(); + await p.cleanup(); + await rm(outside, { recursive: true, force: true }); + } + }); + } + + it("retains discovery roots whose symlinks resolve inside the bound project", async () => { + const p = await project(); + const discovery = owner(p.dir, "writer"); + try { + await rename(join(p.dir, "crew"), join(p.dir, "definitions")); + await symlink(join(p.dir, "definitions"), join(p.dir, "crew")); + const result = getExecutorDiscoveryResultSchema().parse( + await request(discovery, "discovery.describe"), + ); + assert(result.ok); + assertEquals(result.value.definition.id, "writer"); + } finally { + await discovery.close(); + await p.cleanup(); + } + }); + it("loads configured code and markdown only after an operation and retains executable state locally", async () => { const p = await project(); const discovery = owner(p.dir, "writer"); From 024fcf63e8a90edd00e4b2432db83bc36fffbc5c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 00:09:10 +0200 Subject: [PATCH 031/194] fix(agent): audit effective OpenAI sampling controls --- .../model-call-context-request.test.ts | 141 ++++++++++++++++++ src/runtime/model-call-context-request.ts | 44 +++++- 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/src/runtime/model-call-context-request.test.ts b/src/runtime/model-call-context-request.test.ts index da7c981efa..b6738bf136 100644 --- a/src/runtime/model-call-context-request.test.ts +++ b/src/runtime/model-call-context-request.test.ts @@ -8,6 +8,7 @@ import { } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; import { buildModelCallContextRequest } from "#veryfront/runtime/model-call-context-request.ts"; import { buildOpenAIChatRequest } from "../../extensions/ext-llm-openai/src/openai-chat-request-builder.ts"; +import { buildOpenAIResponsesRequest } from "../../extensions/ext-llm-openai/src/openai-responses-request-builder.ts"; const prompt: ModelRuntimeCallOptions["prompt"] = [{ role: "user", @@ -22,6 +23,13 @@ const nativeTools = [{ type: "function", function: { name: "lookup", parameters: { type: "object", properties: {} } }, }]; +const sampling = { temperature: 0.4, topP: 0.8, presencePenalty: 0.3, frequencyPenalty: 0.1 }; +const samplingFields = [ + ["temperature", "temperature"], + ["topP", "top_p"], + ["presencePenalty", "presence_penalty"], + ["frequencyPenalty", "frequency_penalty"], +] as const; describe("model call request projection", () => { for (const modelId of ["gpt-5.4", "gpt-5.5"]) { @@ -159,4 +167,137 @@ describe("model call request projection", () => { ); } }); + + it("omits neutral sampling controls that both OpenAI builders reject", () => { + for (const modelId of ["gpt-5.4", "gpt-5.5", "o3"]) { + for ( + const reasoning of [undefined, { enabled: false }, { + enabled: true, + effort: "max", + }] as const + ) { + const options: ModelRuntimeCallOptions = { prompt, tools, ...sampling, reasoning }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId, + }, options); + for (const stream of [false, true]) { + const bodies = [ + buildOpenAIChatRequest( + modelId, + "veryfront-cloud", + options, + stream, + createWarningCollector(), + { + reasoningWithFunctionTools: resolveVeryfrontCloudOpenAIChatFunctionToolReasoning( + `openai/${modelId}`, + ), + }, + ), + buildOpenAIResponsesRequest( + modelId, + "veryfront-cloud", + options, + stream, + createWarningCollector(), + ), + ]; + for (const body of bodies) { + for (const [field, nativeField] of samplingFields) { + assertEquals((body as Record)[nativeField], undefined); + assertEquals(projected?.[field], (body as Record)[nativeField]); + assertEquals(Object.hasOwn(projected ?? {}, field), false); + } + } + } + } + } + }); + + it("omits sampling when reasoning is explicitly enabled on a nonreasoning model", () => { + const options: ModelRuntimeCallOptions = { + prompt, + ...sampling, + reasoning: { enabled: true, effort: "high" }, + }; + const projected = buildModelCallContextRequest( + { provider: "openai", modelId: "gpt-4o" }, + options, + ); + for (const stream of [false, true]) { + for (const build of [buildOpenAIChatRequest, buildOpenAIResponsesRequest]) { + const body = build("gpt-4o", "openai", options, stream, createWarningCollector()); + for (const [field, nativeField] of samplingFields) { + assertEquals((body as Record)[nativeField], undefined); + assertEquals(projected?.[field], (body as Record)[nativeField]); + } + } + } + }); + + it("retains regular OpenAI Chat sampling and leaves Anthropic and Google controls unchanged", () => { + for (const reasoning of [undefined, { enabled: false }]) { + const options = { prompt, ...sampling, reasoning }; + const projected = buildModelCallContextRequest( + { provider: "openai", modelId: "gpt-4o" }, + options, + ); + for (const stream of [false, true]) { + const body = buildOpenAIChatRequest( + "gpt-4o", + "openai", + options, + stream, + createWarningCollector(), + ); + for (const [field, nativeField] of samplingFields) { + assertEquals(projected?.[field], sampling[field]); + assertEquals(projected?.[field], (body as Record)[nativeField]); + } + } + } + for (const provider of ["anthropic", "google"]) { + assertEquals( + buildModelCallContextRequest({ provider, modelId: "synthetic" }, sampling), + sampling, + ); + } + }); + + it("projects numeric native sampling overrides after neutral sampling is dropped", () => { + const expected = { temperature: 0, topP: 0.6, presencePenalty: -0.2, frequencyPenalty: 0.5 }; + for (const provider of ["openai", "veryfront-cloud"]) { + const options: ModelRuntimeCallOptions = { + prompt, + tools, + ...sampling, + providerOptions: { + "openai-compatible": { temperature: 1 }, + openai: { temperature: 0.9, top_p: 0.6, presence_penalty: -0.2, frequency_penalty: 0.5 }, + [provider]: { + temperature: 0, + top_p: 0.6, + presence_penalty: -0.2, + frequency_penalty: 0.5, + }, + }, + }; + const projected = buildModelCallContextRequest({ + provider, + modelProvider: "openai", + modelId: "gpt-5.5", + }, options); + for (const stream of [false, true]) { + for (const build of [buildOpenAIChatRequest, buildOpenAIResponsesRequest]) { + const body = build("gpt-5.5", provider, options, stream, createWarningCollector()); + for (const [field, nativeField] of samplingFields) { + assertEquals((body as Record)[nativeField], expected[field]); + assertEquals(projected?.[field], (body as Record)[nativeField]); + } + } + } + } + }); }); diff --git a/src/runtime/model-call-context-request.ts b/src/runtime/model-call-context-request.ts index 25edbf51ea..63f91b12ec 100644 --- a/src/runtime/model-call-context-request.ts +++ b/src/runtime/model-call-context-request.ts @@ -3,7 +3,10 @@ import type { RuntimeMetadata, RuntimeReasoningOption, } from "#veryfront/provider/types.ts"; -import { resolveOpenAIReasoningConfig } from "#veryfront/provider/shared/openai-reasoning.ts"; +import { + rejectsOpenAISamplingParams, + resolveOpenAIReasoningConfig, +} from "#veryfront/provider/shared/openai-reasoning.ts"; import { readProviderOptions } from "#veryfront/provider/runtime-loader.ts"; import { resolveVeryfrontCloudOpenAIChatFunctionToolReasoning, @@ -43,7 +46,44 @@ export function buildModelCallContextRequest( model: ModelCallRuntimeMetadata, options: ModelCallRequestSource, ): ModelCallRequest | undefined { - return buildModelCallRequest(options, resolvePersistedReasoning(model, options)); + const reasoning = resolvePersistedReasoning(model, options); + return buildModelCallRequest(resolvePersistedSampling(model, options, reasoning), reasoning); +} + +function resolvePersistedSampling( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, + reasoning: RuntimeReasoningOption | undefined, +): ModelCallRequestSource { + if (resolveModelCallProvider(model) !== "openai" || typeof model.modelId !== "string") { + return options; + } + const providerName = model.provider === "veryfront-cloud" ? "veryfront-cloud" : "openai"; + const providerOptions = readProviderOptions( + options.providerOptions as Record | undefined, + ...(providerName === "openai" ? ["openai-compatible"] : []), + "openai", + providerName, + ); + const dropSampling = reasoning?.enabled === true || rejectsOpenAISamplingParams(model.modelId); + const effective = { ...options }; + for ( + const [field, nativeField] of [ + ["temperature", "temperature"], + ["topP", "top_p"], + ["presencePenalty", "presence_penalty"], + ["frequencyPenalty", "frequency_penalty"], + ] as const + ) { + // Native options merge after neutral filtering in both OpenAI builders. + const value = ObjectHasOwn(providerOptions, nativeField) + ? providerOptions[nativeField] + : dropSampling + ? undefined + : options[field]; + effective[field] = typeof value === "number" ? value : undefined; + } + return effective; } function buildModelCallRequest( From 0601f6fa3247c940cba503773f5c2e2b8b81c3dc Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 00:24:13 +0200 Subject: [PATCH 032/194] fix(agent): audit supported provider controls --- .../hosted/executor-model-dispatch.test.ts | 1 - .../model-call-context-request.test.ts | 262 +++++++++++++++++- src/runtime/model-call-context-request.ts | 160 +++++++++-- 3 files changed, 399 insertions(+), 24 deletions(-) diff --git a/src/agent/hosted/executor-model-dispatch.test.ts b/src/agent/hosted/executor-model-dispatch.test.ts index 0ef69cf41d..01c14165a8 100644 --- a/src/agent/hosted/executor-model-dispatch.test.ts +++ b/src/agent/hosted/executor-model-dispatch.test.ts @@ -167,7 +167,6 @@ describe("hosted executor model dispatch", () => { maxOutputTokens: 17, temperature: 0.4, topP: 0.9, - topK: 2, stopSequences: ["STOP"], seed: 2, presencePenalty: 0.3, diff --git a/src/runtime/model-call-context-request.test.ts b/src/runtime/model-call-context-request.test.ts index b6738bf136..ad304bdaab 100644 --- a/src/runtime/model-call-context-request.test.ts +++ b/src/runtime/model-call-context-request.test.ts @@ -9,6 +9,8 @@ import { import { buildModelCallContextRequest } from "#veryfront/runtime/model-call-context-request.ts"; import { buildOpenAIChatRequest } from "../../extensions/ext-llm-openai/src/openai-chat-request-builder.ts"; import { buildOpenAIResponsesRequest } from "../../extensions/ext-llm-openai/src/openai-responses-request-builder.ts"; +import { buildAnthropicMessagesRequest } from "../../extensions/ext-llm-anthropic/src/anthropic-request-builder.ts"; +import { buildGoogleGenerateContentRequest } from "../../extensions/ext-llm-google/src/google-request-builder.ts"; const prompt: ModelRuntimeCallOptions["prompt"] = [{ role: "user", @@ -237,7 +239,7 @@ describe("model call request projection", () => { } }); - it("retains regular OpenAI Chat sampling and leaves Anthropic and Google controls unchanged", () => { + it("retains regular OpenAI Chat sampling", () => { for (const reasoning of [undefined, { enabled: false }]) { const options = { prompt, ...sampling, reasoning }; const projected = buildModelCallContextRequest( @@ -258,12 +260,6 @@ describe("model call request projection", () => { } } } - for (const provider of ["anthropic", "google"]) { - assertEquals( - buildModelCallContextRequest({ provider, modelId: "synthetic" }, sampling), - sampling, - ); - } }); it("projects numeric native sampling overrides after neutral sampling is dropped", () => { @@ -300,4 +296,256 @@ describe("model call request projection", () => { } } }); + + it("matches Anthropic control omissions and stop limits across effective thinking modes", () => { + for (const provider of ["anthropic", "veryfront-cloud"]) { + for ( + const [configuration, samplingKept] of [ + [{}, true], + [{ reasoning: { enabled: false } }, true], + [{ reasoning: { enabled: true, budgetTokens: 2048 } }, false], + [{ + providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2048 } } }, + }, false], + [{ + reasoning: { enabled: false }, + providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2048 } } }, + }, false], + [{ providerOptions: { anthropic: { thinking: { type: "adaptive" } } } }, true], + [{ providerOptions: { anthropic: { thinking: { type: "disabled" } } } }, true], + ] as const + ) { + const options: ModelRuntimeCallOptions = { + prompt, + ...sampling, + maxOutputTokens: 64, + topK: 9, + seed: 7, + stopSequences: ["A", "B", "C", "D", "E"], + ...configuration, + }; + const projected = buildModelCallContextRequest({ + provider, + modelProvider: "anthropic", + modelId: "claude-haiku-4-5", + }, options); + assertEquals(projected?.maxOutputTokens, 64); + for (const stream of [false, true]) { + const body = buildAnthropicMessagesRequest( + "claude-haiku-4-5", + provider, + options, + stream, + createWarningCollector(), + ) as unknown as Record; + for ( + const [field, nativeField] of [...samplingFields, ["topK", "top_k"], [ + "seed", + "seed", + ]] as const + ) { + const expected = samplingKept && (field === "temperature" || field === "topP") + ? sampling[field] + : undefined; + assertEquals(body[nativeField], expected); + assertEquals(projected?.[field], body[nativeField]); + } + assertEquals(projected?.stopSequences, ["A", "B", "C", "D"]); + assertEquals(projected?.stopSequences, body.stop_sequences); + } + } + } + }); + + it("preserves Anthropic native control overrides after neutral filtering", () => { + const options: ModelRuntimeCallOptions = { + prompt, + ...sampling, + topK: 9, + seed: 7, + stopSequences: ["neutral"], + providerOptions: { + anthropic: { thinking: { type: "enabled", budget_tokens: 2048 }, temperature: 1 }, + "veryfront-cloud": { + temperature: 0, + top_p: 0.2, + top_k: 17, + seed: 0, + presence_penalty: 0.7, + frequency_penalty: -0.5, + stop_sequences: ["native"], + }, + }, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "anthropic", + modelId: "claude-haiku-4-5", + }, options); + assertEquals(projected, { + temperature: 0, + topP: 0.2, + topK: 17, + seed: 0, + presencePenalty: 0.7, + frequencyPenalty: -0.5, + stopSequences: ["native"], + reasoning: { enabled: true, budgetTokens: 2048 }, + }); + for (const stream of [false, true]) { + const body = buildAnthropicMessagesRequest( + "claude-haiku-4-5", + "veryfront-cloud", + options, + stream, + createWarningCollector(), + ) as unknown as Record; + for ( + const [field, nativeField] of [...samplingFields, ["topK", "top_k"], ["seed", "seed"], [ + "stopSequences", + "stop_sequences", + ]] as const + ) { + assertEquals(projected?.[field], body[nativeField]); + } + } + }); + + it("matches Google generation controls with and without neutral thinking", () => { + for ( + const reasoning of [undefined, { enabled: false }, { + enabled: true, + budgetTokens: 1024, + }] as const + ) { + const options: ModelRuntimeCallOptions = { + prompt, + ...sampling, + maxOutputTokens: 64, + topK: 9, + seed: 7, + stopSequences: ["STOP"], + reasoning, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "google", + modelId: "gemini-synthetic", + }, options); + const body = buildGoogleGenerateContentRequest( + "veryfront-cloud", + options, + createWarningCollector(), + ); + for ( + const [field] of [...samplingFields, ["maxOutputTokens"], ["topK"], ["seed"], [ + "stopSequences", + ]] as const + ) { + assertEquals(projected?.[field], body.generationConfig?.[field]); + } + assertEquals(projected?.presencePenalty, undefined); + assertEquals(projected?.frequencyPenalty, undefined); + assertEquals(projected?.reasoning, reasoning); + } + }); + + it("uses Google's replacement generationConfig for controls and representable thinking", () => { + for ( + const thinkingConfig of [undefined, { thinkingBudget: 512 }, { thinkingBudget: -1 }, { + thinkingLevel: "unrepresentable", + }] + ) { + const generationConfig = { + temperature: 0, + presencePenalty: 0.7, + frequencyPenalty: -0.5, + ...(thinkingConfig ? { thinkingConfig } : {}), + }; + const options: ModelRuntimeCallOptions = { + prompt, + ...sampling, + maxOutputTokens: 64, + topK: 9, + seed: 7, + stopSequences: ["neutral"], + reasoning: { enabled: true, budgetTokens: 1024 }, + providerOptions: { + google: { generationConfig: { topK: 3 } }, + "veryfront-cloud": { generationConfig }, + }, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "google", + modelId: "gemini-synthetic", + }, options); + const body = buildGoogleGenerateContentRequest( + "veryfront-cloud", + options, + createWarningCollector(), + ); + for ( + const [field] of [...samplingFields, ["maxOutputTokens"], ["topK"], ["seed"], [ + "stopSequences", + ]] as const + ) { + assertEquals(projected?.[field], body.generationConfig?.[field]); + } + assertEquals( + projected?.reasoning, + thinkingConfig?.thinkingBudget === -1 + ? { enabled: true, effort: "max" } + : thinkingConfig?.thinkingBudget === 512 + ? { enabled: true, budgetTokens: 512 } + : undefined, + ); + } + }); + + it("omits OpenAI neutral topK while preserving copied native top_k and seed overrides", () => { + for (const provider of ["openai", "veryfront-cloud"]) { + for (const native of [undefined, { top_k: 0, seed: 2 }]) { + const options: ModelRuntimeCallOptions = { + prompt, + topK: 9, + ...(native ? { seed: 7, providerOptions: { [provider]: native } } : {}), + }; + const projected = buildModelCallContextRequest({ + provider, + modelProvider: "openai", + modelId: "gpt-4o", + }, options); + for (const stream of [false, true]) { + for (const build of [buildOpenAIChatRequest, buildOpenAIResponsesRequest]) { + const body = build( + "gpt-4o", + provider, + options, + stream, + createWarningCollector(), + ) as unknown as Record; + assertEquals(projected?.topK, body.top_k); + assertEquals(projected?.seed, body.seed); + assertEquals(projected?.topK, native?.top_k); + } + } + } + } + }); + + it("omits empty neutral stop lists for known providers and preserves unknown provider controls", () => { + for (const provider of ["openai", "anthropic", "google"]) { + assertEquals( + buildModelCallContextRequest({ provider, modelId: "synthetic" }, { stopSequences: [] }) + ?.stopSequences, + undefined, + ); + } + const options = { ...sampling, topK: 9, seed: 7, stopSequences: [] }; + assertEquals( + buildModelCallContextRequest({ provider: "custom", modelId: "synthetic" }, options), + options, + ); + }); }); diff --git a/src/runtime/model-call-context-request.ts b/src/runtime/model-call-context-request.ts index 63f91b12ec..22e6c7a2a5 100644 --- a/src/runtime/model-call-context-request.ts +++ b/src/runtime/model-call-context-request.ts @@ -41,21 +41,50 @@ function readOwnEnumerableDataDescriptor( : undefined; } +function readProviderControl( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, + key: string, +): PropertyDescriptor | undefined { + const provider = resolveModelCallProvider(model); + let selected: PropertyDescriptor | undefined; + for (const name of [provider, model.provider ?? provider]) { + if (!name) continue; + const bucket = readOwnEnumerableDataDescriptor(options.providerOptions, name)?.value; + if (Array.isArray(bucket)) continue; + selected = readOwnEnumerableDataDescriptor(bucket, key) ?? selected; + } + return selected; +} + +function numberControl(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function stopControl(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? [...value] + : undefined; +} + /** Project effective request settings without persisting raw provider options. */ export function buildModelCallContextRequest( model: ModelCallRuntimeMetadata, options: ModelCallRequestSource, ): ModelCallRequest | undefined { const reasoning = resolvePersistedReasoning(model, options); - return buildModelCallRequest(resolvePersistedSampling(model, options, reasoning), reasoning); + return buildModelCallRequest(resolvePersistedControls(model, options, reasoning), reasoning); } -function resolvePersistedSampling( +function resolvePersistedControls( model: ModelCallRuntimeMetadata, options: ModelCallRequestSource, reasoning: RuntimeReasoningOption | undefined, ): ModelCallRequestSource { - if (resolveModelCallProvider(model) !== "openai" || typeof model.modelId !== "string") { + const provider = resolveModelCallProvider(model); + if (provider === "anthropic") return resolveAnthropicControls(model, options); + if (provider === "google") return resolveGoogleControls(model, options); + if (provider !== "openai") { return options; } const providerName = model.provider === "veryfront-cloud" ? "veryfront-cloud" : "openai"; @@ -65,8 +94,20 @@ function resolvePersistedSampling( "openai", providerName, ); - const dropSampling = reasoning?.enabled === true || rejectsOpenAISamplingParams(model.modelId); - const effective = { ...options }; + const dropSampling = reasoning?.enabled === true || + (typeof model.modelId === "string" && rejectsOpenAISamplingParams(model.modelId)); + const effective = { + ...options, + topK: numberControl(providerOptions.top_k), + seed: ObjectHasOwn(providerOptions, "seed") + ? numberControl(providerOptions.seed) + : options.seed, + stopSequences: ObjectHasOwn(providerOptions, "stop") + ? stopControl(providerOptions.stop) + : options.stopSequences?.length + ? options.stopSequences + : undefined, + }; for ( const [field, nativeField] of [ ["temperature", "temperature"], @@ -86,9 +127,78 @@ function resolvePersistedSampling( return effective; } +function resolveAnthropicControls( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): ModelCallRequestSource { + const thinking = readProviderControl(model, options, "thinking")?.value; + // Adaptive native thinking is copied as-is; only enabled budget thinking + // triggers the Messages builder's neutral sampling filter. + const thinkingEnabled = options.reasoning?.enabled === true || + readOwnEnumerableDataDescriptor(thinking, "type")?.value === "enabled"; + const effective = { ...options }; + for ( + const [field, nativeField] of [ + ["temperature", "temperature"], + ["topP", "top_p"], + ["topK", "top_k"], + ["seed", "seed"], + ["presencePenalty", "presence_penalty"], + ["frequencyPenalty", "frequency_penalty"], + ] as const + ) { + const native = readProviderControl(model, options, nativeField); + effective[field] = native + ? numberControl(native.value) + : !thinkingEnabled && (field === "temperature" || field === "topP") + ? options[field] + : undefined; + } + const stops = readProviderControl(model, options, "stop_sequences"); + effective.stopSequences = stops + ? stopControl(stops.value) + : options.stopSequences?.length + ? options.stopSequences.slice(0, 4) + : undefined; + // maxOutputTokens remains the neutral output budget, independent of the + // provider's combined output/thinking max_tokens allowance. + return effective; +} + +function resolveGoogleControls( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): ModelCallRequestSource { + const native = readProviderControl(model, options, "generationConfig"); + const effective = { + ...options, + presencePenalty: undefined as number | undefined, + frequencyPenalty: undefined as number | undefined, + stopSequences: options.stopSequences?.length ? options.stopSequences : undefined, + }; + if (!native) return effective; + // The builder replaces generationConfig wholesale, rather than merging + // its fields over the neutral controls. + for ( + const field of [ + "maxOutputTokens", + "temperature", + "topP", + "topK", + "seed", + "presencePenalty", + "frequencyPenalty", + ] as const + ) effective[field] = numberControl(readOwnEnumerableDataDescriptor(native.value, field)?.value); + effective.stopSequences = stopControl( + readOwnEnumerableDataDescriptor(native.value, "stopSequences")?.value, + ); + return effective; +} + function buildModelCallRequest( options: ModelCallRequestSource, - reasoning = options.reasoning, + reasoning: RuntimeReasoningOption | undefined, ): ModelCallRequest | undefined { const projectedReasoning = reasoning ? { @@ -128,6 +238,7 @@ function resolvePersistedReasoning( options: ModelCallRequestSource, ): RuntimeReasoningOption | undefined { const modelProvider = resolveModelCallProvider(model); + if (modelProvider === "google") return resolveGoogleReasoning(model, options); if (modelProvider === "openai" && typeof model.modelId === "string") { const catalogId = `openai/${model.modelId}`; if ( @@ -162,15 +273,7 @@ function resolvePersistedReasoning( return options.reasoning; } - const providerOptions = options.providerOptions; - if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { - return options.reasoning; - } - const anthropic = readOwnEnumerableDataDescriptor(providerOptions, "anthropic")?.value; - if (!anthropic || typeof anthropic !== "object" || Array.isArray(anthropic)) { - return options.reasoning; - } - const thinking = readOwnEnumerableDataDescriptor(anthropic, "thinking")?.value; + const thinking = readProviderControl(model, options, "thinking")?.value; if (!thinking || typeof thinking !== "object" || Array.isArray(thinking)) { return options.reasoning; } @@ -192,7 +295,7 @@ function resolvePersistedReasoning( }; } - const outputConfig = readOwnEnumerableDataDescriptor(anthropic, "output_config")?.value; + const outputConfig = readProviderControl(model, options, "output_config")?.value; const effort = outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) ? readOwnEnumerableDataDescriptor(outputConfig, "effort")?.value : undefined; @@ -203,3 +306,28 @@ function resolvePersistedReasoning( : {}), }; } + +function resolveGoogleReasoning( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): RuntimeReasoningOption | undefined { + const native = readProviderControl(model, options, "generationConfig"); + if (!native) return options.reasoning; + const thinking = readOwnEnumerableDataDescriptor(native.value, "thinkingConfig")?.value; + const budget = readOwnEnumerableDataDescriptor(thinking, "thinkingBudget")?.value; + if (typeof budget !== "number" || !Number.isSafeInteger(budget) || budget < -1) return undefined; + const neutral = options.reasoning; + const neutralBudget = neutral?.budgetTokens ?? + (neutral?.effort === "low" + ? 512 + : neutral?.effort === "high" + ? 8192 + : neutral?.effort === "max" + ? -1 + : 2048); + if ( + neutral?.enabled === true && budget === neutralBudget && + readOwnEnumerableDataDescriptor(thinking, "includeThoughts")?.value === true + ) return neutral; + return budget === -1 ? { enabled: true, effort: "max" } : { enabled: true, budgetTokens: budget }; +} From c92bfdd4531b366b39439d917bc5d0c8dd58ac1f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 00:40:49 +0200 Subject: [PATCH 033/194] fix(agent): reject executor events after termination --- src/agent/hosted/executor-agent-bridge.test.ts | 16 ++++++++++++++++ src/agent/hosted/executor-agent-bridge.ts | 1 + src/agent/streaming/executor-data-stream.ts | 1 + 3 files changed, 18 insertions(+) diff --git a/src/agent/hosted/executor-agent-bridge.test.ts b/src/agent/hosted/executor-agent-bridge.test.ts index ead794582b..6916c6ce63 100644 --- a/src/agent/hosted/executor-agent-bridge.test.ts +++ b/src/agent/hosted/executor-agent-bridge.test.ts @@ -238,6 +238,8 @@ describe("executor hosted agent bridge", () => { 'data: {"type":"text-delta","delta":42}\n\n', 'data: {"type":"text-delta","delta":"unfinished"}\n\n', 'data: {"type":"finish","finishReason":"tool-calls"}\n\n', + 'data: {"type":"message-finish"}\n\ndata: {"type":"text-delta","delta":"late"}\n\n', + 'data: {"type":"error","error":"failed"}\n\ndata: {"type":"message-finish"}\n\n', 'data: {"type":"message-finish"}', "data: not-json\n\n", ] @@ -276,6 +278,20 @@ describe("executor hosted agent bridge", () => { const invalidFrames: JsonValue[][] = [ [{ type: "ready" }], + [{ type: "ready" }, { + type: "event", + event: { type: "message-finish" }, + }, { + type: "event", + event: { type: "text-delta", delta: "late" }, + }, { type: "complete" }], + [{ type: "ready" }, { + type: "event", + event: { type: "error", error: "failed" }, + }, { + type: "event", + event: { type: "message-finish" }, + }, { type: "complete" }], [{ type: "ready" }, { type: "event", event: { type: "finish", finishReason: "tool-calls" }, diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 0225fc8c24..81cbb89ed0 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -169,6 +169,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { if (next.done) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); const frame = parseFrame(next.value); if (frame.type === "event") { + if (terminal) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); const event = parseExecutorDataEvent(frame.event); terminal ||= event.type === "message-finish" || event.type === "error"; controller.enqueue( diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index cc92d8afb5..e1731d332f 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -54,6 +54,7 @@ export async function* readExecutorDataEvents( const fragment = next.value.subarray(offset, offset + 4096); validator.decode(fragment, { stream: true }); for (const byte of fragment) { + if (terminal) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); if (pendingBytes === pending.length) { const grown = new Uint8Array( Math.min(pending.length * 2, EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + 2), From 69a4c65fd538ca49b34d94fd0762130796fd7f36 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 07:37:31 +0200 Subject: [PATCH 034/194] fix(runtime): align managed model context with effective provider controls --- .../model-call-context-request.test.ts | 180 ++++++++++++++++++ src/runtime/model-call-context-request.ts | 161 ++++++++++++---- 2 files changed, 301 insertions(+), 40 deletions(-) diff --git a/src/runtime/model-call-context-request.test.ts b/src/runtime/model-call-context-request.test.ts index ad304bdaab..d56edf1618 100644 --- a/src/runtime/model-call-context-request.test.ts +++ b/src/runtime/model-call-context-request.test.ts @@ -34,6 +34,186 @@ const samplingFields = [ ] as const; describe("model call request projection", () => { + it("matches OpenAI-compatible Cloud controls including Kimi fixed sampling", () => { + for ( + const [modelProvider, modelId] of [["mistral", "mistral-large"], [ + "moonshotai", + "kimi-k2.5", + ]] as const + ) { + for (const native of [undefined, { temperature: 0.2, top_k: 3 }]) { + const options = { + prompt, + ...sampling, + topK: 9, + providerOptions: native ? { "veryfront-cloud": native } : undefined, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider, + modelId, + }, options); + const body = buildOpenAIChatRequest( + modelId, + "veryfront-cloud", + options, + false, + createWarningCollector(), + ); + for (const [field, nativeField] of [...samplingFields, ["topK", "top_k"]] as const) { + assertEquals(projected?.[field], (body as Record)[nativeField]); + } + } + } + }); + + it("matches managed OpenAI native reasoning precedence and tool suppression", () => { + for (const modelId of ["gpt-5.4", "o3"]) { + for (const nativeEffort of ["low", "none", undefined]) { + const native = modelId === "o3" + ? { reasoning: { effort: nativeEffort } } + : { reasoning_effort: nativeEffort }; + const options = { + prompt, + ...sampling, + reasoning: { enabled: true, effort: "high" as const }, + providerOptions: { + openai: { reasoning_effort: "medium", reasoning: { effort: "medium" } }, + "veryfront-cloud": native, + }, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId, + }, options); + const body = modelId === "o3" + ? buildOpenAIResponsesRequest( + modelId, + "veryfront-cloud", + options, + false, + createWarningCollector(), + ) + : buildOpenAIChatRequest( + modelId, + "veryfront-cloud", + options, + false, + createWarningCollector(), + { reasoningWithFunctionTools: false }, + ); + assertEquals( + modelId === "o3" ? (body.reasoning as { effort?: string }).effort : body.reasoning_effort, + nativeEffort, + ); + assertEquals( + projected?.reasoning, + nativeEffort === "low" + ? { enabled: true, effort: "low" } + : nativeEffort === "none" + ? { enabled: false } + : undefined, + ); + assertEquals(projected?.temperature, body.temperature); + if (modelId === "gpt-5.4") { + assertEquals( + buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId, + }, { ...options, tools })?.reasoning, + { enabled: false }, + ); + } + } + } + }); + + it("omits neutral seeds from managed Responses while retaining native seeds", () => { + for (const native of [undefined, { seed: 2 }]) { + const options = { + prompt, + seed: 7, + providerOptions: native ? { "veryfront-cloud": native } : undefined, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId: "o3", + }, options); + const body = buildOpenAIResponsesRequest( + "o3", + "veryfront-cloud", + options, + false, + createWarningCollector(), + ); + assertEquals(projected?.seed, body.seed); + assertEquals(projected?.seed, native?.seed); + } + }); + + it("matches controls when managed web-search tools select Responses", () => { + const options: ModelRuntimeCallOptions = { + prompt, + ...sampling, + seed: 7, + stopSequences: ["STOP"], + tools: [{ type: "provider", id: "openai.web_search", name: "web_search", args: {} }], + providerOptions: { "veryfront-cloud": { reasoning: { effort: "low" } } }, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "openai", + modelId: "gpt-4o", + }, options); + const body = buildOpenAIResponsesRequest( + "gpt-4o", + "veryfront-cloud", + options, + false, + createWarningCollector(), + ); + for ( + const [field, nativeField] of [...samplingFields, ["seed", "seed"], [ + "stopSequences", + "stop", + ]] as const + ) { + assertEquals(projected?.[field], body[nativeField]); + } + assertEquals(projected?.reasoning, { enabled: true, effort: "low" }); + }); + + it("omits adaptive effort overwritten by Anthropic structured output", () => { + const options: ModelRuntimeCallOptions = { + prompt, + responseFormat: { + type: "json_schema", + name: "result", + schema: { type: "object", properties: {} }, + }, + providerOptions: { + anthropic: { thinking: { type: "adaptive" }, output_config: { effort: "high" } }, + }, + }; + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider: "anthropic", + modelId: "claude-opus-4-7", + }, options); + const body = buildAnthropicMessagesRequest( + "claude-opus-4-7", + "veryfront-cloud", + options, + false, + createWarningCollector(), + ); + assertEquals(projected?.reasoning, { enabled: true }); + assertEquals((body.output_config as Record).effort, undefined); + }); + for (const modelId of ["gpt-5.4", "gpt-5.5"]) { it(`matches ${modelId} Cloud Chat reasoning with and without function tools`, () => { const catalogId = `openai/${modelId}`; diff --git a/src/runtime/model-call-context-request.ts b/src/runtime/model-call-context-request.ts index 22e6c7a2a5..dcea36b0fd 100644 --- a/src/runtime/model-call-context-request.ts +++ b/src/runtime/model-call-context-request.ts @@ -4,20 +4,24 @@ import type { RuntimeReasoningOption, } from "#veryfront/provider/types.ts"; import { + isOpenAIReasoningModel, rejectsOpenAISamplingParams, resolveOpenAIReasoningConfig, } from "#veryfront/provider/shared/openai-reasoning.ts"; import { readProviderOptions } from "#veryfront/provider/runtime-loader.ts"; import { + resolveVeryfrontCloudModelThinking, resolveVeryfrontCloudOpenAIChatFunctionToolReasoning, resolveVeryfrontCloudOpenAITransport, } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; import type { ModelCallRequest } from "./model-call-context.ts"; type ModelCallRuntimeMetadata = Pick; -type ModelCallRequestSource = Pick & { - providerOptions?: unknown; -}; +type ModelCallRequestSource = + & Pick + & { + providerOptions?: unknown; + }; const ReflectApply = Reflect.apply; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; @@ -67,43 +71,78 @@ function stopControl(value: unknown): string[] | undefined { : undefined; } +function usesOpenAIBuilder(model: ModelCallRuntimeMetadata): boolean { + const provider = resolveModelCallProvider(model); + return provider === "openai" || (model.provider === "veryfront-cloud" && + (provider === "mistral" || provider === "moonshotai")); +} + +function managedOpenAITransport( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): "chat-completions" | "responses" | undefined { + // Custom direct runtime transport overrides are not represented by this metadata. + if (model.provider !== "veryfront-cloud" || !model.modelId) return undefined; + const catalogId = `${resolveModelCallProvider(model)}/${model.modelId}`; + return resolveVeryfrontCloudOpenAITransport(catalogId) ?? + ((resolveModelCallProvider(model) === "openai" && + resolveVeryfrontCloudModelThinking(catalogId)?.enabled === true) || + isOpenAIReasoningModel(model.modelId, "veryfront-cloud") || + options.tools?.some((tool) => tool.type === "provider" && tool.id.startsWith("openai.")) + ? "responses" + : "chat-completions"); +} + +function openAIProviderOptions( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): Record { + const providerName = model.provider === "veryfront-cloud" ? "veryfront-cloud" : "openai"; + return readProviderOptions( + options.providerOptions as Record | undefined, + ...(providerName === "openai" ? ["openai-compatible"] : []), + "openai", + providerName, + ); +} + /** Project effective request settings without persisting raw provider options. */ export function buildModelCallContextRequest( model: ModelCallRuntimeMetadata, options: ModelCallRequestSource, ): ModelCallRequest | undefined { const reasoning = resolvePersistedReasoning(model, options); - return buildModelCallRequest(resolvePersistedControls(model, options, reasoning), reasoning); + return buildModelCallRequest(resolvePersistedControls(model, options), reasoning); } function resolvePersistedControls( model: ModelCallRuntimeMetadata, options: ModelCallRequestSource, - reasoning: RuntimeReasoningOption | undefined, ): ModelCallRequestSource { const provider = resolveModelCallProvider(model); if (provider === "anthropic") return resolveAnthropicControls(model, options); if (provider === "google") return resolveGoogleControls(model, options); - if (provider !== "openai") { + if (!usesOpenAIBuilder(model)) { return options; } - const providerName = model.provider === "veryfront-cloud" ? "veryfront-cloud" : "openai"; - const providerOptions = readProviderOptions( - options.providerOptions as Record | undefined, - ...(providerName === "openai" ? ["openai-compatible"] : []), - "openai", - providerName, - ); - const dropSampling = reasoning?.enabled === true || - (typeof model.modelId === "string" && rejectsOpenAISamplingParams(model.modelId)); + const providerOptions = openAIProviderOptions(model, options); + const transport = managedOpenAITransport(model, options); + // Native reasoning is merged after neutral sampling is filtered. + const dropSampling = resolveOpenAINeutralReasoning(model, options)?.enabled === true || + (typeof model.modelId === "string" && (rejectsOpenAISamplingParams(model.modelId) || + (transport !== "responses" && /^kimi-k2\.5/.test(model.modelId)))); const effective = { ...options, topK: numberControl(providerOptions.top_k), seed: ObjectHasOwn(providerOptions, "seed") ? numberControl(providerOptions.seed) + : transport === "responses" + ? undefined : options.seed, stopSequences: ObjectHasOwn(providerOptions, "stop") ? stopControl(providerOptions.stop) + : transport === "responses" + ? undefined : options.stopSequences?.length ? options.stopSequences : undefined, @@ -119,7 +158,9 @@ function resolvePersistedControls( // Native options merge after neutral filtering in both OpenAI builders. const value = ObjectHasOwn(providerOptions, nativeField) ? providerOptions[nativeField] - : dropSampling + : dropSampling || + (transport === "responses" && + (field === "presencePenalty" || field === "frequencyPenalty")) ? undefined : options[field]; effective[field] = typeof value === "number" ? value : undefined; @@ -239,34 +280,71 @@ function resolvePersistedReasoning( ): RuntimeReasoningOption | undefined { const modelProvider = resolveModelCallProvider(model); if (modelProvider === "google") return resolveGoogleReasoning(model, options); - if (modelProvider === "openai" && typeof model.modelId === "string") { - const catalogId = `openai/${model.modelId}`; + if (usesOpenAIBuilder(model) && typeof model.modelId === "string") { + const neutral = resolveOpenAINeutralReasoning(model, options); + const transport = managedOpenAITransport(model, options); + if (!transport) return neutral; + if (suppressOpenAIFunctionToolReasoning(model, options)) return { enabled: false }; + const native = openAIProviderOptions(model, options); + const field = transport === "responses" ? "reasoning" : "reasoning_effort"; + if (!ObjectHasOwn(native, field)) return neutral; + const effort = transport === "responses" + ? readOwnEnumerableDataDescriptor(native.reasoning, "effort")?.value + : native.reasoning_effort; + if (effort === "none") return { enabled: false }; + return effort === "low" || effort === "medium" || effort === "high" || effort === "max" + ? { enabled: true, effort } + : undefined; + } + + return resolveNonOpenAIReasoning(model, options); +} + +function suppressOpenAIFunctionToolReasoning( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): boolean { + const catalogId = `openai/${model.modelId}`; + if ( + model.provider === "veryfront-cloud" && + resolveVeryfrontCloudOpenAITransport(catalogId) === "chat-completions" && + resolveVeryfrontCloudOpenAIChatFunctionToolReasoning(catalogId) === false + ) { + // Match the Chat builder's native bucket precedence, including an own + // tools value that clears the neutral list with [] or undefined. + const providerOptions = openAIProviderOptions(model, options); + const tools = ObjectHasOwn(providerOptions, "tools") ? providerOptions.tools : options.tools; if ( - model.provider === "veryfront-cloud" && - resolveVeryfrontCloudOpenAITransport(catalogId) === "chat-completions" && - resolveVeryfrontCloudOpenAIChatFunctionToolReasoning(catalogId) === false + Array.isArray(tools) && + tools.some((tool) => + tool !== null && typeof tool === "object" && "type" in tool && tool.type === "function" + ) ) { - // Match the Chat builder's native bucket precedence, including an own - // tools value that clears the neutral list with [] or undefined. - const providerOptions = readProviderOptions( - options.providerOptions as Record | undefined, - "openai", - "veryfront-cloud", - ); - const tools = ObjectHasOwn(providerOptions, "tools") ? providerOptions.tools : options.tools; - if ( - Array.isArray(tools) && - tools.some((tool) => - tool !== null && typeof tool === "object" && "type" in tool && tool.type === "function" - ) - ) { - return { enabled: false }; - } + return true; } - const reasoning = resolveOpenAIReasoningConfig(model.modelId, modelProvider, options.reasoning); - return reasoning ? { enabled: true, effort: reasoning.effort } : options.reasoning; } + return false; +} +function resolveOpenAINeutralReasoning( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): RuntimeReasoningOption | undefined { + if (suppressOpenAIFunctionToolReasoning(model, options)) return { enabled: false }; + if (!model.modelId) return options.reasoning; + const reasoning = resolveOpenAIReasoningConfig( + model.modelId, + model.provider === "veryfront-cloud" ? "veryfront-cloud" : "openai", + options.reasoning, + ); + return reasoning ? { enabled: true, effort: reasoning.effort } : options.reasoning; +} + +function resolveNonOpenAIReasoning( + model: ModelCallRuntimeMetadata, + options: ModelCallRequestSource, +): RuntimeReasoningOption | undefined { + const modelProvider = resolveModelCallProvider(model); // The Anthropic request builder only gives neutral reasoning precedence when // it enables thinking; otherwise a raw provider thinking config remains effective. if (modelProvider !== "anthropic" || options.reasoning?.enabled === true) { @@ -295,7 +373,10 @@ function resolvePersistedReasoning( }; } - const outputConfig = readProviderControl(model, options, "output_config")?.value; + // Structured output is pinned again after provider options are merged. + const outputConfig = options.responseFormat?.type === "json_schema" + ? undefined + : readProviderControl(model, options, "output_config")?.value; const effort = outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) ? readOwnEnumerableDataDescriptor(outputConfig, "effort")?.value : undefined; From 8c1a7f05464c22b0d14d4ff0e8d102dc6e7a2692 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 07:45:39 +0200 Subject: [PATCH 035/194] test(runtime): verify compatible Cloud reasoning through the runtime bridge --- .../model-call-context-request.test.ts | 30 +++++++++++++++++++ src/runtime/runtime-bridge.test.ts | 3 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/runtime/model-call-context-request.test.ts b/src/runtime/model-call-context-request.test.ts index d56edf1618..8087b8c154 100644 --- a/src/runtime/model-call-context-request.test.ts +++ b/src/runtime/model-call-context-request.test.ts @@ -130,6 +130,36 @@ describe("model call request projection", () => { } }); + it("projects compatible Cloud reasoning effort without an unsupported token budget", () => { + for ( + const [modelProvider, modelId] of [["mistral", "mistral-large"], [ + "moonshotai", + "kimi-k2.5", + ]] as const + ) { + const options = { + prompt, + reasoning: { enabled: true, effort: "high" as const, budgetTokens: 2048 }, + }; + const body = buildOpenAIChatRequest( + modelId, + "veryfront-cloud", + options, + false, + createWarningCollector(), + ); + const projected = buildModelCallContextRequest({ + provider: "veryfront-cloud", + modelProvider, + modelId, + }, options); + assertEquals(body.reasoning_effort, "high"); + assertEquals(projected?.reasoning, { enabled: true, effort: "high" }); + assertEquals(projected?.reasoning?.effort, body.reasoning_effort); + assertEquals(projected?.reasoning?.budgetTokens, undefined); + } + }); + it("omits neutral seeds from managed Responses while retaining native seeds", () => { for (const native of [undefined, { seed: 2 }]) { const options = { diff --git a/src/runtime/runtime-bridge.test.ts b/src/runtime/runtime-bridge.test.ts index e483717c0d..246134211f 100644 --- a/src/runtime/runtime-bridge.test.ts +++ b/src/runtime/runtime-bridge.test.ts @@ -815,7 +815,8 @@ describe("runtime-bridge", () => { assertEquals(recorded?.model, { id: bareModelId, modelProvider }); assertEquals( recorded?.request?.reasoning, - modelProvider === "openai" + // Cloud Mistral uses the same OpenAI-compatible effort-only builder. + modelProvider === "openai" || modelProvider === "mistral" ? { enabled: true, effort: "high" } : { enabled: true, effort: "high", budgetTokens: 2048 }, ); From c1a1ba1e1031ed01021b47226393c565b3f113e2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 08:35:30 +0200 Subject: [PATCH 036/194] Prepare executor runtimes from installed grants and private facades --- src/agent/factory.test.ts | 20 +- src/agent/factory.ts | 25 +- .../hosted/chat-runtime-tool-assembly.test.ts | 46 + .../hosted/chat-runtime-tool-assembly.ts | 164 ++- src/agent/hosted/default-chat-runtime.ts | 62 +- src/agent/hosted/executor-discovery.test.ts | 27 + src/agent/hosted/executor-discovery.ts | 20 +- .../hosted/executor-runtime-prepare-schema.ts | 105 ++ .../hosted/executor-runtime-prepare.test.ts | 1283 +++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 558 +++++++ src/agent/runtime/index.ts | 19 +- src/agent/runtime/model-transport.test.ts | 25 + src/agent/runtime/model-transport.ts | 19 +- .../runtime/runtime-stream-cancel.test.ts | 48 + 14 files changed, 2341 insertions(+), 80 deletions(-) create mode 100644 src/agent/hosted/executor-runtime-prepare-schema.ts create mode 100644 src/agent/hosted/executor-runtime-prepare.test.ts create mode 100644 src/agent/hosted/executor-runtime-prepare.ts diff --git a/src/agent/factory.test.ts b/src/agent/factory.test.ts index 9cae56349c..7cb8e9358b 100644 --- a/src/agent/factory.test.ts +++ b/src/agent/factory.test.ts @@ -14,7 +14,7 @@ import { VeryfrontError } from "#veryfront/errors"; import { getEffectiveAgentSystem } from "./runtime/effective-agent-system.ts"; import { getAvailableTools } from "./runtime/tool-helpers.ts"; import { agentRegistry } from "./composition/index.ts"; -import { agent } from "./factory.ts"; +import { agent, createEphemeralAgentWithRuntimeOptions } from "./factory.ts"; import { resolveSkillToolDisposition } from "./skill-tool-disposition.ts"; import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; import type { AgentConfig, AgentResponse } from "./types.ts"; @@ -77,6 +77,24 @@ function createLoadSkillModel(skillId: string): ModelRuntime { } describe("agent factory", () => { + it("requires an explicit catalog when framework construction preserves tool authority", () => { + for ( + const config of [ + { id: "strict-catalog", tools: true as const }, + { id: "strict-catalog", tools: {}, delegates: ["another-agent"] }, + ] + ) { + assertThrows( + () => + createEphemeralAgentWithRuntimeOptions({ ...config, system: "Synthetic" }, { + preserveToolCatalog: true, + }), + TypeError, + "explicit tool catalog", + ); + } + }); + beforeEach(() => { agentRegistry.clearAll(); skillRegistryInternal.clearAll(); diff --git a/src/agent/factory.ts b/src/agent/factory.ts index c3992e403c..0515989b09 100644 --- a/src/agent/factory.ts +++ b/src/agent/factory.ts @@ -564,13 +564,24 @@ function createAgent( registerConfiguredLocalTools(config); - const mergedToolsConfig = resolveToolsConfiguration({ - config, - id, - delegates, - skillTools: resolveSkillToolDisposition(config, id), - resolveSkillSnapshot, - }); + let mergedToolsConfig: AgentConfig["tools"]; + if (options.runtimeOptions?.preserveToolCatalog === true) { + if (config.tools === true || delegates !== undefined) { + throw new TypeError( + "A prevalidated agent requires an explicit tool catalog without delegates", + ); + } + ensureBuiltinSchemaValidator(); + mergedToolsConfig = { ...(config.tools ?? {}) }; + } else { + mergedToolsConfig = resolveToolsConfiguration({ + config, + id, + delegates, + skillTools: resolveSkillToolDisposition(config, id), + resolveSkillSnapshot, + }); + } const augmentedSystem = createAugmentedSystem({ config, diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index bf8e25ecd4..18424ca6f9 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -19,9 +19,55 @@ import { filterHostedChatRuntimeLocalTools, type HostedChatRuntimeToolAssemblyContext, prepareConfigDerivedHostedChatRuntimeToolAssembly, + prepareFacadedHostedChatRuntimeToolAssembly, prepareHostedChatRuntimeToolAssembly, } from "./chat-runtime-tool-assembly.ts"; +it("facaded assembly preserves project tool normalization and mutation callbacks without private transport config", async () => { + const calls: Array> = []; + let mutations = 0; + const source: RemoteToolSource = { + id: "api", + listTools: () => + Promise.resolve([{ + ...remoteTool("update_file", "Update file"), + parameters: { + type: "object", + properties: { path: { type: "string" }, project_reference: { type: "string" } }, + required: ["project_reference"], + }, + }, remoteTool("delete_file", "Delete file")]), + executeTool: (_name, args) => { + calls.push(args); + return Promise.resolve({ ok: true }); + }, + }; + const assembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + taskContext: { + projectId: "project-1", + branchId: null, + model: "veryfront-cloud/openai/gpt-5.4", + }, + instructions: "Synthetic instructions", + localTools: {}, + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + allowedToolNames: ["update_file"], + remoteToolSources: [source], + onSteeringMutation: () => { + mutations++; + }, + }); + assertEquals(assembly.availableToolNames, ["update_file"]); + await assembly.remoteToolSources[0]!.executeTool("update_file", { + path: "AGENTS.md", + project_reference: "untrusted-project", + }); + assertEquals(calls, [{ path: "AGENTS.md", project_reference: "project-1" }]); + assertEquals(mutations, 1); + await assertRejects(() => assembly.remoteToolSources[0]!.executeTool("delete_file", {})); + assertEquals(calls.length, 1); +}); + const unrestrictedSourceIntegrationPolicy = { schemaVersion: 1, mode: "unrestricted", diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 59e4defc58..d5ecfaf8eb 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -19,6 +19,7 @@ import { } from "../artifacts/default-research-artifact-support.ts"; import { type AgentServiceMcpServerConfig } from "../service/mcp-server-config.ts"; import { + createHostedProjectRemoteToolSource, createHostedProjectRemoteToolSources, type HostedProjectRemoteToolSourceMutationHandler, type HostedProjectRemoteToolSourcePrepareToolInput, @@ -147,6 +148,31 @@ export type PrepareHostedChatRuntimeToolAssemblyInput< sourceIntegrationPolicy: SourceIntegrationPolicyManifest; }; +/** @internal Tool assembly with executor-local facades, without private transport configuration. */ +export type PrepareFacadedHostedChatRuntimeToolAssemblyInput< + TTraceAttributes extends HostToolTraceAttributes = HostToolTraceAttributes, +> = + & Omit< + PrepareHostedChatRuntimeToolAssemblyInput, + | "taskContext" + | "apiUrl" + | "apiMcpUrl" + | "studioMcpUrl" + | "mcpServers" + | "createRemoteToolSource" + | "preloadLatestConversationUserText" + > + & { + taskContext: Omit; + remoteToolSources: readonly RemoteToolSource[]; + loadLatestConversationUserText?: () => Promise; + }; + +type FacadedHostedChatRuntimeToolAssemblyResult = HostedChatRuntimeToolAssemblyResult & { + normalizedAllowedToolNames: ReadonlySet | null; + authorizedToolNames: string[]; +}; + /** * Widen the Veryfront API MCP server's allowlist with a run's server-resolved * integration tools. @@ -239,23 +265,27 @@ function applyHostedHostToolPolicy( ); } -function activeProjectId(taskContext: HostedChatRuntimeToolAssemblyContext): string | null { +function activeProjectId( + taskContext: Omit, +): string | null { return taskContext.projectId || null; } -function activeBranchId(taskContext: HostedChatRuntimeToolAssemblyContext): string | null { +function activeBranchId( + taskContext: Omit, +): string | null { return taskContext.branchId ?? null; } function hasSubmittedFormInputResult( - taskContext: HostedChatRuntimeToolAssemblyContext, + taskContext: Omit, ): boolean { return taskContext.submittedFormInputResult !== undefined; } function filterPostFormInputLocalTools( tools: HostToolSet, - taskContext: HostedChatRuntimeToolAssemblyContext, + taskContext: Omit, ): HostToolSet { if (!hasSubmittedFormInputResult(taskContext)) { return tools; @@ -288,7 +318,8 @@ function resolveOwnerScopedToolName(input: { return input.toolName; } -function resolveOwnerScopedToolNames(input: { +/** @internal Normalize selectors against the owning agent's local tool catalog. */ +export function resolveOwnerScopedToolNames(input: { toolNames: HostedChatRuntimeAllowedToolNames | undefined; agentId?: string; localTools: HostToolSet; @@ -351,9 +382,11 @@ function shouldIncludeHostedWebFetchFallback(input: { async function prepareHostedChatRuntimeToolAssemblyInternal< TTraceAttributes extends HostToolTraceAttributes = HostToolTraceAttributes, >( - input: PrepareHostedChatRuntimeToolAssemblyInput, + input: + | PrepareHostedChatRuntimeToolAssemblyInput + | PrepareFacadedHostedChatRuntimeToolAssemblyInput, configDerivedSelector: boolean, -): Promise { +): Promise { const authorizedLocalTools = withoutDeniedHostTools( applyHostedHostToolPolicy(input.localTools, input.hostToolPolicy), input.deniedToolNames, @@ -412,37 +445,59 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const localHostTools = input.traceLocalTools ? traceHostTools(sortedLocalTools, input.traceLocalTools) : sortedLocalTools; - const createRemoteToolSource = input.createRemoteToolSource ?? createRemoteMCPToolSource; + const createRemoteToolSource = "remoteToolSources" in input + ? undefined + : input.createRemoteToolSource ?? createRemoteMCPToolSource; const remoteToolSources = withoutDeniedRemoteTools( - createHostedProjectRemoteToolSources({ - authToken: input.taskContext.authToken, - apiMcpUrl: input.apiMcpUrl, - studioMcpUrl: input.studioMcpUrl, - mcpServers: augmentVeryfrontApiMcpServerPolicy( - input.mcpServers, - input.serverResolvedIntegrationToolNames, - ), - clientProfile: input.taskContext.clientProfile, - // Project-scoped sources perform retry calls against their input source. - // Apply the denial at this inner boundary as well as the returned source - // so a retry cannot invoke a denied companion tool. - createRemoteToolSource: (config) => - withoutDeniedRemoteTool(createRemoteToolSource(config), input.deniedToolNames), - defaultProjectId: () => activeProjectId(input.taskContext), - getProjectId: input.getProjectId ?? (() => activeProjectId(input.taskContext)), - getActiveBranchId: input.getActiveBranchId ?? (() => activeBranchId(input.taskContext)), - conversationId: input.conversationId, - allowedToolNames, - ...(input.toolDiscoveryContext?.activatedRemoteToolNames !== undefined - ? { activatedRemoteToolNames: input.toolDiscoveryContext.activatedRemoteToolNames } - : {}), - projectScopedRemoteToolOptions: input.projectScopedRemoteToolOptions, - prepareToolInput: input.prepareRemoteToolInput, - shouldRetryWithTool: input.shouldRetryWithRemoteTool, - onSteeringMutation: input.onSteeringMutation, - onStudioProjectSwitch: input.onStudioProjectSwitch, - }), + "remoteToolSources" in input + ? input.remoteToolSources.map((source) => + createHostedProjectRemoteToolSource({ + source: withoutDeniedRemoteTool( + wrapRemoteToolSourceWithMcpPolicy( + source, + allowedToolNames === null ? undefined : { allow: [...allowedToolNames] }, + ), + input.deniedToolNames, + ), + defaultProjectId: () => activeProjectId(input.taskContext), + getActiveBranchId: () => activeBranchId(input.taskContext), + allowedToolNames, + projectScopedRemoteToolOptions: input.projectScopedRemoteToolOptions, + prepareToolInput: input.prepareRemoteToolInput, + shouldRetryWithTool: input.shouldRetryWithRemoteTool, + onProjectSwitch: input.onStudioProjectSwitch, + onSteeringMutation: input.onSteeringMutation, + }) + ) + : createHostedProjectRemoteToolSources({ + authToken: input.taskContext.authToken, + apiMcpUrl: input.apiMcpUrl, + studioMcpUrl: input.studioMcpUrl, + mcpServers: augmentVeryfrontApiMcpServerPolicy( + input.mcpServers, + input.serverResolvedIntegrationToolNames, + ), + clientProfile: input.taskContext.clientProfile, + // Project-scoped sources perform retry calls against their input source. + // Apply the denial at this inner boundary as well as the returned source + // so a retry cannot invoke a denied companion tool. + createRemoteToolSource: (config) => + withoutDeniedRemoteTool(createRemoteToolSource!(config), input.deniedToolNames), + defaultProjectId: () => activeProjectId(input.taskContext), + getProjectId: input.getProjectId ?? (() => activeProjectId(input.taskContext)), + getActiveBranchId: input.getActiveBranchId ?? (() => activeBranchId(input.taskContext)), + conversationId: input.conversationId, + allowedToolNames, + ...(input.toolDiscoveryContext?.activatedRemoteToolNames !== undefined + ? { activatedRemoteToolNames: input.toolDiscoveryContext.activatedRemoteToolNames } + : {}), + projectScopedRemoteToolOptions: input.projectScopedRemoteToolOptions, + prepareToolInput: input.prepareRemoteToolInput, + shouldRetryWithTool: input.shouldRetryWithRemoteTool, + onSteeringMutation: input.onSteeringMutation, + onStudioProjectSwitch: input.onStudioProjectSwitch, + }), input.deniedToolNames, ); const listedRemoteToolNames = await listProjectScopedRemoteToolNames(remoteToolSources, { @@ -474,7 +529,10 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< selectedProviderToolNames, input.sourceIntegrationPolicy, ); - const localToolNames = Object.keys(localHostTools); + // Materialize before validation and provider capping so skipped descriptors + // cannot advertise capabilities that the runtime cannot execute. + const localRuntimeTools = createToolsFromHostDefinitions(localHostTools); + const localToolNames = Object.keys(localRuntimeTools); const toolSearchDenied = deniedProviderToolNames.has(TOOL_SEARCH_TOOL_NAME); const toolLoadingMode: RuntimeToolLoadingMode = normalizedAllowedToolNames === null && !toolSearchDenied @@ -493,12 +551,12 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< requiredToolNames: localToolNames, }); const compatibleToolNames = new Set(availableToolNames); - const compatibleLocalHostTools = toolLoadingMode === "deferred" - ? localHostTools + const compatibleLocalRuntimeTools = toolLoadingMode === "deferred" + ? localRuntimeTools : Object.fromEntries( - Object.entries(localHostTools).filter(([toolName]) => compatibleToolNames.has(toolName)), + Object.entries(localRuntimeTools).filter(([toolName]) => compatibleToolNames.has(toolName)), ); - const compatibleLocalToolNames = Object.keys(compatibleLocalHostTools); + const compatibleLocalToolNames = Object.keys(compatibleLocalRuntimeTools); const compatibleRemoteToolNames = toolLoadingMode === "deferred" ? remoteToolNames : remoteToolNames.filter((toolName) => compatibleToolNames.has(toolName)); @@ -526,7 +584,15 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ? undefined : instructionsWithToolInventory; - if (input.preloadLatestConversationUserText !== false) { + if ("remoteToolSources" in input) { + if (input.loadLatestConversationUserText) { + updateDefaultResearchArtifacts({ + taskContext: input.taskContext, + latestUserText: await input.loadLatestConversationUserText(), + system: systemInstructions, + }); + } + } else if (input.preloadLatestConversationUserText !== false) { const latestUserText = await fetchLatestConversationUserText({ apiUrl: input.apiUrl, authToken: input.taskContext.authToken, @@ -540,8 +606,10 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< } return { + normalizedAllowedToolNames, + authorizedToolNames, sourceIntegrationPolicy: input.sourceIntegrationPolicy, - runtimeTools: createToolsFromHostDefinitions(compatibleLocalHostTools), + runtimeTools: compatibleLocalRuntimeTools, remoteToolSources, localToolNames: compatibleLocalToolNames, remoteToolNames, @@ -575,3 +643,13 @@ export function prepareConfigDerivedHostedChatRuntimeToolAssembly< input.includeRuntimeEssentialToolsWhenEmpty === true, ); } + +/** @internal Use prepared operation facades without constructing credentialed clients. */ +export function prepareFacadedHostedChatRuntimeToolAssembly( + input: PrepareFacadedHostedChatRuntimeToolAssemblyInput, +): Promise { + return prepareHostedChatRuntimeToolAssemblyInternal( + input, + input.includeRuntimeEssentialToolsWhenEmpty === true, + ); +} diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index d90572c312..13902bdcee 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -196,7 +196,8 @@ function createDefaultTaskContext( }; } -function incrementSteeringRevision(context: DefaultHostedChatRuntimeTaskContext): void { +/** @internal Share steering invalidation with facaded runtime preparation. */ +export function incrementSteeringRevision(context: HostedRuntimeStateResolverContext): void { context.steeringRevision = (context.steeringRevision ?? 0) + 1; } @@ -287,24 +288,19 @@ async function buildToolAssembly( }; } -function createRuntimeAgentConfig(input: { - options: DefaultHostedChatRuntimeCreationOptions; - taskContext: DefaultHostedChatRuntimeTaskContext; +/** @internal Shared runtime construction after transport-free tool assembly. */ +export type PreparedHostedRuntimeAgentOptions = { + options: Omit; + taskContext: HostedRuntimeStateResolverContext; toolAssembly: HostedChatRuntimeToolAssemblyResult; modelId: string; sourceIntegrationPolicy: SourceIntegrationPolicyManifest; - refreshSystem?: CreateDefaultHostedChatRuntimeOptions["refreshSystem"]; -}): AgentConfig { + refreshSystem?: () => Promise | AgentSystem; +}; + +function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): AgentConfig { const liveProjectSteering = input.options.liveProjectSteering; - const systemRefresh = input.refreshSystem; - const refreshSystem = systemRefresh && liveProjectSteering - ? () => - systemRefresh({ - taskContext: input.taskContext, - liveProjectSteering, - toolAssembly: input.toolAssembly, - }) - : undefined; + const refreshSystem = input.refreshSystem; const runtimeTools = Object.fromEntries( Object.entries(input.toolAssembly.runtimeTools).map(([toolName, runtimeTool]) => [ @@ -514,17 +510,24 @@ export async function createDefaultHostedChatRuntime( effectiveRunEventWriterCapability, () => buildToolAssembly({ ...input, taskContext, cloudContext }), ); - const runtimeAgentConfig = createRuntimeAgentConfig({ - options: input.options, - taskContext, - toolAssembly, - modelId, - sourceIntegrationPolicy: input.sourceIntegrationPolicy, - refreshSystem: input.refreshSystem, - }); + const refreshSystem = input.refreshSystem; + const liveProjectSteering = input.options.liveProjectSteering; const runtimeAgent = runWithVeryfrontCloudContext( cloudContext, - () => createEphemeralAgentWithRuntimeOptions(runtimeAgentConfig, runtimeOptions), + () => + createPreparedHostedRuntimeAgent({ + options: input.options, + taskContext, + toolAssembly, + modelId, + sourceIntegrationPolicy: input.sourceIntegrationPolicy, + ...(refreshSystem && liveProjectSteering + ? { + refreshSystem: () => + refreshSystem({ taskContext, liveProjectSteering, toolAssembly }), + } + : {}), + }, runtimeOptions), ); return { @@ -575,3 +578,14 @@ export async function createDefaultHostedChatRuntime( }, ); } + +/** @internal Construct from explicit runtime state; no private transport or credential defaults. */ +export function createPreparedHostedRuntimeAgent( + input: PreparedHostedRuntimeAgentOptions, + runtimeOptions: AgentRuntimeInternalOptions, +) { + return createEphemeralAgentWithRuntimeOptions(createRuntimeAgentConfig(input), { + ...runtimeOptions, + modelCallThinking: runtimeOptions.modelCallThinking ?? input.options.thinking, + }); +} diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index f4af87c247..5c6dee7e5f 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -353,6 +353,33 @@ describe("executor discovery operations", () => { assertEquals(cleaned, 1); }); + it("joins producer work retained by startup after upstream cancellation", async () => { + const f = fixture(); + await call(f.owner, "discovery.describe"); + const resumeStartup = Promise.withResolvers(); + const producer = Promise.withResolvers(); + const startup = resumeStartup.promise.then(() => { + f.owner.retainRuntimeTask(producer.promise); + }); + f.owner.retainRuntimeTask(startup); + f.controller.abort(); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(f.cleanups, 0); + resumeStartup.resolve(); + await startup; + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(f.cleanups, 0); + } finally { + resumeStartup.resolve(); + producer.resolve(); + await startup; + await f.owner.settled; + } + assertEquals(f.cleanups, 1); + assertThrows(() => f.owner.retainRuntimeTask(Promise.resolve()), ExecutorDiscoveryError); + }); + it("cleans partial discovery even when the loader throws a registered configuration error", async () => { let cleaned = 0; const f = fixture({ diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 123f4562d1..d949b6aa72 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -47,7 +47,13 @@ export interface ExecutorDiscovery { readonly signal: AbortSignal; /** Local-only access for the next runtime.prepare stage. */ getRuntime(): ProjectAgentRuntimeDiscovery; - /** Resolves after all discovery/projection and partial-resource cleanup settle. */ + /** + * Retain original runtime work before cleanup starts, including work spawned + * by retained startup after cancellation. Tasks must not await discovery + * operations, close(), or settled; those can themselves await cleanup. + */ + retainRuntimeTask(task: Promise): void; + /** Resolves after discovery, retained runtime work, and partial-resource cleanup settle. */ readonly settled: Promise; close(): Promise; } @@ -84,6 +90,8 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut let setupFailed = false; let runtime: ProjectAgentRuntimeDiscovery | undefined; let closing: Promise | undefined; + let cleanupStarted = false; + const runtimeTasks = new Set>(); const definitions = new Map(); const helpers = () => import("../project/agent-runtime.ts"); @@ -95,6 +103,10 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut // Memoize before synchronous abort listeners can reenter close(). closing = tail.then(async () => { try { + // Re-read after each batch: retained startup may reserve producer work + // after cancellation, before its own promise settles. + while (runtimeTasks.size > 0) await Promise.all(runtimeTasks); + cleanupStarted = true; if (loadStarted) await backend?.cleanup(runtime); } catch { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLEANUP_FAILED"); @@ -291,6 +303,12 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut signal: lifetime.signal, settled: settled.promise, close, + retainRuntimeTask(task) { + if (cleanupStarted) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLOSED"); + const retained = task.then(() => {}, () => {}); + runtimeTasks.add(retained); + void retained.then(() => runtimeTasks.delete(retained)); + }, getRuntime() { assertActive(); if (!runtime || !discovered) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_NOT_READY"); diff --git a/src/agent/hosted/executor-runtime-prepare-schema.ts b/src/agent/hosted/executor-runtime-prepare-schema.ts new file mode 100644 index 0000000000..143f358d15 --- /dev/null +++ b/src/agent/hosted/executor-runtime-prepare-schema.ts @@ -0,0 +1,105 @@ +import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; +import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; +import { defineError, VeryfrontError } from "#veryfront/errors/types.ts"; +import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; + +const failureStatus = { + EXECUTOR_RUNTIME_INVALID_INPUT: 400, + EXECUTOR_RUNTIME_NOT_GRANTED: 403, + EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE: 503, + EXECUTOR_RUNTIME_ALREADY_PREPARED: 409, + EXECUTOR_RUNTIME_NOT_PREPARED: 409, + EXECUTOR_RUNTIME_PREPARATION_FAILED: 500, + EXECUTOR_RUNTIME_CLEANUP_FAILED: 500, + EXECUTOR_RUNTIME_CLOSED: 410, + ABORTED: 499, +} as const; +export class ExecutorRuntimePreparationError extends VeryfrontError { + constructor(readonly code: keyof typeof failureStatus) { + super( + code, + defineError({ + slug: code.toLowerCase().replaceAll("_", "-"), + category: "AGENT", + status: failureStatus[code], + title: code, + }), + ); + } +} +const getNames = defineSchema((v) => v.array(getExecutorDiscoveryIdSchema()).max(256)); +const getPositiveLimit = defineSchema((v) => + v.number().int().positive().max(Number.MAX_SAFE_INTEGER) +); +export const getExecutorRuntimePrepareRequestSchema = defineSchema((v) => + v.object({ + agentId: getExecutorDiscoveryIdSchema(), + modelId: getExecutorDiscoveryIdSchema().optional(), + instructions: v.union([ + v.string(), + v.array( + v.object({ + role: v.literal("system"), + content: v.string(), + providerOptions: v.record(v.string(), getJsonValueSchema()).optional(), + }).strict(), + ).max(256), + ]).optional(), + temperature: v.number().min(0).max(2).optional(), + thinking: v.object({ enabled: v.boolean(), budgetTokens: getPositiveLimit().optional() }) + .strict().optional(), + maxSteps: getPositiveLimit().optional(), + maxOutputTokens: getPositiveLimit().optional(), + allowedToolNames: getNames().optional(), + providerToolNames: getNames().optional(), + }).strict() +); +export type ExecutorRuntimePrepareRequest = InferSchema< + ReturnType +>; + +export const getExecutorRuntimeGrantDataSchema = defineSchema((v) => { + const context = { + projectId: v.string().nullable(), + projectSlug: v.string().optional(), + branchId: v.string().nullable().optional(), + userId: v.string().optional(), + }; + return v.object({ + agentId: getExecutorDiscoveryIdSchema(), + defaultModelId: getExecutorDiscoveryIdSchema(), + maxSteps: getPositiveLimit(), + models: v.array( + v.object({ + id: getExecutorDiscoveryIdSchema(), + maxOutputTokens: getPositiveLimit(), + providerToolNames: getNames(), + }).strict(), + ).min(1).max(128), + allowedToolNames: getNames(), + hostToolFacadeIds: getNames(), + remoteToolSourceIds: getNames(), + requiredCapabilities: v.array(v.enum(["project-steering", "conversation-user-text"] as const)) + .max(2).optional(), + execution: v.discriminatedUnion("kind", [ + v.object({ kind: v.literal("ephemeral"), ...context }).strict(), + v.object({ + kind: v.literal("canonical"), + ...context, + conversationId: getExecutorDiscoveryIdSchema(), + runId: getExecutorDiscoveryIdSchema(), + messageId: getExecutorDiscoveryIdSchema(), + providerReplay: v.enum(["required", "disabled"] as const), + }).strict(), + ]), + }).strict(); +}); +export type ExecutorRuntimeGrantData = InferSchema< + ReturnType +>; + +export function parseRuntimePreparationData(schema: Schema, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_INVALID_INPUT"); + return result.data; +} diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts new file mode 100644 index 0000000000..9f271c24fd --- /dev/null +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -0,0 +1,1283 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { PERMISSION_DENIED } from "#veryfront/errors"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import type { AgentConfig } from "../types.ts"; +import type { HostToolDefinition, ToolDefinition } from "#veryfront/tool"; +import { registerModelRuntimeResolverRevoker } from "../runtime/model-transport.ts"; +import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; +import { agent } from "../factory.ts"; +import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; +import { createExecutorDiscovery } from "./executor-discovery.ts"; +import { + createExecutorRuntimePreparation, + type ExecutorRuntimeFacades, + type ExecutorRuntimePreparationGrant, +} from "./executor-runtime-prepare.ts"; +import { ExecutorRuntimePreparationError } from "./executor-runtime-prepare-schema.ts"; +import { createExecutorChannel } from "../executor/channel.ts"; +import { createExecutorHostedChatRuntimeAgent } from "./executor-agent-bridge.ts"; + +const binding = { + allocationId: "prepare-allocation", + invocationId: "prepare-invocation", + generation: 1, +}; +const source = { type: "release", releaseId: "synthetic-release" } as const; +const modelId = "veryfront-cloud/openai/gpt-5.4"; +const model: ModelRuntime = { + modelId: "gpt-5.4", + provider: "openai", + specificationVersion: "v3", + doGenerate: () => Promise.reject(new Error("Unexpected generate call")), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", id: "text-1", delta: "Synthetic answer" }); + controller.enqueue({ + type: "finish", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 2 }, + }); + controller.close(); + }, + }), + }), +}; +function runtime(config: Partial = {}): ProjectAgentRuntimeDiscovery { + const coder = agent({ + id: "coder", + system: "Synthetic source instructions.", + model: modelId, + tools: true, + ...config, + }); + return { + agents: new Map([[coder.id, coder]]), + tools: new Map(), + 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 grant: ExecutorRuntimePreparationGrant = { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, +}; +function fixture( + overrides: { + grant?: ExecutorRuntimePreparationGrant | null; + facades?: Partial; + config?: Partial; + load?: () => Promise; + } = {}, +) { + let cleanups = 0; + let discoveryCleanups = 0; + const controller = new AbortController(); + const discovery = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + signal: controller.signal, + backend: { + load: overrides.load ?? (() => Promise.resolve(runtime(overrides.config))), + cleanup: () => { + discoveryCleanups++; + return Promise.resolve(); + }, + }, + }); + const owner = createExecutorRuntimePreparation({ + binding, + source, + discovery, + grant: overrides.grant === null ? undefined : overrides.grant ?? grant, + facades: { + resolveModelRuntime: () => model, + hostTools: new Map(), + remoteToolSources: new Map(), + cleanup: () => { + cleanups++; + return Promise.resolve(); + }, + ...overrides.facades, + }, + }); + return { + owner, + discovery, + controller, + get cleanups() { + return cleanups; + }, + get discoveryCleanups() { + return discoveryCleanups; + }, + }; +} +async function prepare( + owner: ReturnType["owner"], + value: JsonValue = { agentId: "coder" }, +) { + const operation = owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + return await operation.handle(value, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }); +} + +describe("executor runtime preparation", () => { + it("keeps skill references and scripts outside a loader-only grant after loading a skill", async () => { + const visible: string[][] = []; + const f = fixture({ + grant: { ...grant, allowedToolNames: ["load_skill"], hostToolFacadeIds: ["skills"] }, + facades: { + hostTools: new Map([["skills", { + load_skill: { + description: "Synthetic loader", + inputSchema: defineSchema((v) => v.object({ skillId: v.string() }))(), + execute: () => + Promise.resolve({ + skillId: "example", + instructions: "Synthetic instructions", + references: ["references/example.md"], + scripts: ["scripts/example.ts"], + }), + }, + }]]), + projectSteering: { + prepare: ({ definition }) => + Promise.resolve({ + agent: definition, + initialSkills: [{ + id: "example", + name: "example", + description: "Synthetic", + instructions: "Synthetic", + allowedTools: [], + }], + }), + refresh: () => "Synthetic instructions", + }, + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + visible.push( + (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? [], + ); + return finishStream(visible.length === 1 ? "load_skill" : undefined, { + skillId: "example", + }); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(visible, [["load_skill"], ["load_skill"]]); + } finally { + await f.owner.close(); + } + }); + + it("requires a locally installed grant even after discovery", async () => { + const f = fixture({ grant: null }); + try { + assertEquals(await prepare(f.owner), { ok: false, code: "EXECUTOR_RUNTIME_NOT_GRANTED" }); + } finally { + await f.owner.close(); + } + }); + + it("rejects a model grant that would bypass the managed model resolver", () => { + assertThrows( + () => + fixture({ + grant: { + ...grant, + defaultModelId: "openai/gpt-5.4", + models: new Map([["openai/gpt-5.4", { maxOutputTokens: 200, providerToolNames: [] }]]), + }, + }), + ExecutorRuntimePreparationError, + ); + }); + + it("does not allow streaming before runtime preparation", async () => { + const f = fixture(); + try { + const stream = f.owner.operations.get("agent.stream"); + assert(stream?.mode === "stream"); + await assertRejects( + () => + Array.fromAsync( + stream.handle({}, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 10_000, + }), + ), + ExecutorRuntimePreparationError, + ); + const result = await prepare(f.owner); + assert( + result !== null && typeof result === "object" && !Array.isArray(result) && + result.ok === true, + ); + } finally { + await f.owner.close(); + } + }); + + it("preserves sanitized private facade setup errors and cleans partial preparation", async () => { + const f = fixture({ + grant: { ...grant, remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => + Promise.reject(PERMISSION_DENIED.create({ detail: "synthetic-private-diagnostic" })), + executeTool: () => Promise.reject(new Error("Unused")), + }]]), + }, + }); + try { + assertEquals(await prepare(f.owner), { ok: false, code: "PERMISSION_DENIED" }); + assertEquals(f.cleanups, 1); + } finally { + await f.owner.close(); + } + }); + + it("rejects source, mode, and authority fields from wire preparation", async () => { + const values: JsonValue[] = [{ agentId: "coder", source }, { + agentId: "coder", + execution: { kind: "ephemeral" }, + }, { agentId: "coder", authToken: "synthetic" }]; + for (const value of values) { + const f = fixture(); + try { + assertEquals(await prepare(f.owner, value), { + ok: false, + code: "EXECUTOR_RUNTIME_INVALID_INPUT", + }); + } finally { + await f.owner.close(); + } + } + }); + + it("rejects agent mismatch and model/output escalation before runtime assembly", async () => { + const values: JsonValue[] = [{ agentId: "other" }, { + agentId: "coder", + modelId: "veryfront-cloud/openai/ungranted", + }, { agentId: "coder", maxOutputTokens: 201 }]; + for (const value of values) { + const f = fixture(); + try { + assertEquals(await prepare(f.owner, value), { + ok: false, + code: "EXECUTOR_RUNTIME_NOT_GRANTED", + }); + } finally { + await f.owner.close(); + } + } + }); + + it("requires all declared private facades before preparing any sources", async () => { + let listed = 0; + const f = fixture({ + grant: { ...grant, hostToolFacadeIds: ["sandbox"], remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => { + listed++; + return Promise.resolve([]); + }, + executeTool: () => Promise.reject(new Error("Unused")), + }]]), + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(listed, 0); + } finally { + await f.owner.close(); + } + }); + + it("never downgrades canonical preparation when checkpoint or parent persistence is unavailable", async () => { + const f = fixture({ + grant: { + ...grant, + execution: { + kind: "canonical", + projectId: "project-1", + conversationId: "conversation-1", + runId: "run-1", + messageId: "message-1", + providerReplay: "required", + }, + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + } finally { + await f.owner.close(); + } + }); + + it("prepares once, streams through the existing raw adapter, and cleans local resources once", async () => { + const f = fixture(); + const forward = new TransformStream(); + const backward = new TransformStream(); + const broker = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const executor = createExecutorChannel({ + binding, + operations: f.owner.operations, + transport: { readable: forward.readable, writable: backward.writable }, + }); + try { + const result = await broker.request("runtime.prepare", { + agentId: "coder", + maxOutputTokens: 100, + }); + assert( + result !== null && typeof result === "object" && !Array.isArray(result) && + result.ok === true, + ); + const prepared = result.value; + assert(prepared !== null && typeof prepared === "object" && !Array.isArray(prepared)); + assert(typeof prepared.preparedRuntimeHandle === "string"); + assertEquals(prepared.modelId, modelId); + assertEquals(await broker.request("runtime.prepare", { agentId: "coder" }), { + ok: false, + code: "EXECUTOR_RUNTIME_ALREADY_PREPARED", + }); + const remote = createExecutorHostedChatRuntimeAgent({ + channel: broker, + preparedRuntimeHandle: prepared.preparedRuntimeHandle, + }); + const response = await remote.stream({ + messages: [{ + id: "message-1", + role: "user", + parts: [{ type: "text", text: "Synthetic question" }], + timestamp: 1, + }], + abortSignal: new AbortController().signal, + }); + const chunks = await Array.fromAsync( + response.toUIMessageStream({ generateMessageId: () => "broker-message" }), + ); + assert( + chunks.some((chunk) => chunk.type === "text-delta" && chunk.delta === "Synthetic answer"), + ); + assertEquals(chunks.at(-1)?.type, "finish"); + assertEquals(f.cleanups, 1); + assertEquals(broker.signal.aborted, false); + } finally { + broker.close(); + await executor.closed; + await f.owner.close(); + } + assertEquals(f.cleanups, 1); + assertEquals(f.discoveryCleanups, 1); + }); + + it("joins late preparation and cleanup after cancellation", async () => { + const listing = Promise.withResolvers<[]>(); + const entered = Promise.withResolvers(); + const f = fixture({ + grant: { ...grant, remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => { + entered.resolve(); + return listing.promise; + }, + executeTool: () => Promise.reject(new Error("Unused")), + }]]), + }, + }); + const pending = prepare(f.owner); + await entered.promise; + const closing = f.owner.close(); + assertEquals(f.cleanups, 0); + listing.resolve([]); + assertEquals(await pending, { ok: false, code: "ABORTED" }); + await closing; + assertEquals(f.cleanups, 1); + assertEquals(f.discoveryCleanups, 1); + assertEquals(await prepare(f.owner), { ok: false, code: "EXECUTOR_RUNTIME_CLOSED" }); + }); +}); + +async function preparedStream( + f: ReturnType, + request: JsonValue = { agentId: "coder" }, + userText = "Synthetic question", +) { + const result = await prepare(f.owner, request); + assert(result && typeof result === "object" && !Array.isArray(result) && result.ok === true); + const operation = f.owner.operations.get("agent.stream"); + assert(operation?.mode === "stream"); + const prepared = result.value; + assert(prepared && typeof prepared === "object" && !Array.isArray(prepared)); + return operation.handle({ + preparedRuntimeHandle: prepared.preparedRuntimeHandle!, + messages: [{ + id: "synthetic-message", + role: "user", + parts: [{ type: "text", text: userText }], + timestamp: 1, + }], + }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }); +} + +function finishStream(toolName?: string, input: Record = {}) { + return Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + if (toolName) { + controller.enqueue({ type: "tool-call", toolCallId: "synthetic-call", toolName, input }); + } + controller.enqueue({ + type: "finish", + finishReason: toolName ? "tool-calls" : "stop", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + controller.close(); + }, + }), + }); +} + +function syntheticHostTool() { + return { + description: "Synthetic tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }; +} + +function syntheticRemoteTool(name: string): ToolDefinition { + return { name, description: "Synthetic tool", parameters: { type: "object", properties: {} } }; +} + +describe("executor runtime preparation review regressions", () => { + it("retains discovery resources during preparation after upstream abort", async () => { + const entered = Promise.withResolvers(); + const listing = Promise.withResolvers<[]>(); + const f = fixture({ + grant: { ...grant, remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => { + entered.resolve(); + return listing.promise; + }, + executeTool: () => Promise.reject(new Error("Unused")), + }]]), + }, + }); + const pending = prepare(f.owner); + await entered.promise; + f.controller.abort(); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(f.cleanups, 0); + assertEquals(f.discoveryCleanups, 0); + } finally { + listing.resolve([]); + assertEquals(await pending, { ok: false, code: "ABORTED" }); + await Promise.all([f.owner.settled, f.discovery.settled]); + } + assertEquals(f.cleanups, 1); + assertEquals(f.discoveryCleanups, 1); + }); + + for (const outcome of ["abort", "failure"] as const) { + it(`settles preparation and discovery without a cleanup cycle on discovery ${outcome}`, async () => { + const entered = Promise.withResolvers(); + const loaded = Promise.withResolvers(); + const f = fixture({ + load: () => { + entered.resolve(); + return loaded.promise; + }, + }); + const pending = prepare(f.owner); + await entered.promise; + if (outcome === "abort") { + f.controller.abort(); + loaded.resolve(runtime()); + } else { + loaded.reject(new Error("Synthetic discovery failure")); + } + assertEquals(await pending, { ok: false, code: "ABORTED" }); + await Promise.all([f.owner.settled, f.discovery.settled]); + assertEquals(f.cleanups, 0); + assertEquals(f.discoveryCleanups, 1); + }); + } + + for (const cancellation of ["owner close", "upstream discovery abort"] as const) { + for (const phase of ["startup", "model", "tool"] as const) { + it(`joins original ${phase} work after ${cancellation} before cleanup and settlement`, async () => { + const entered = Promise.withResolvers(); + const unblock = Promise.withResolvers(); + const finished = Promise.withResolvers(); + const wait = async () => { + entered.resolve(); + try { + await unblock.promise; + } finally { + finished.resolve(); + } + }; + let calls = 0; + const f = fixture({ + grant: { ...grant, allowedToolNames: ["work"], remoteToolSourceIds: ["api"] }, + facades: { + resolveModelRuntime: () => ({ + ...model, + ...(phase === "startup" ? { prepare: wait } : {}), + doStream: async () => { + if (phase === "model") await wait(); + return finishStream(phase === "tool" && calls++ === 0 ? "work" : undefined); + }, + }), + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => Promise.resolve([syntheticRemoteTool("work")]), + executeTool: async () => { + await wait(); + return { ok: true }; + }, + }]]), + }, + }); + const stream = await preparedStream(f); + const consuming = Array.fromAsync(stream).catch(() => []); + await entered.promise; + let settled = false; + void f.owner.settled.then(() => { + settled = true; + }); + let discoverySettled = false; + void f.discovery.settled.then(() => { + discoverySettled = true; + }); + if (cancellation === "upstream discovery abort") f.controller.abort(); + const closing = cancellation === "owner close" ? f.owner.close() : f.owner.settled; + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(f.cleanups, 0); + assertEquals(f.discoveryCleanups, 0); + assertEquals(settled, false); + assertEquals(discoverySettled, false); + } finally { + unblock.resolve(); + await finished.promise; + await closing; + await f.discovery.settled; + await consuming; + } + assertEquals(f.cleanups, 1); + assertEquals(f.discoveryCleanups, 1); + }); + } + } + + for (const outcome of ["complete", "failure", "abort"] as const) { + it(`forwards installed resolver revocation on ${outcome}`, async () => { + let revocations = 0; + const entered = Promise.withResolvers(); + const unblock = Promise.withResolvers(); + const resolver = () => ({ + ...model, + doStream: async () => { + entered.resolve(); + if (outcome === "abort") await unblock.promise; + if (outcome === "failure") throw new Error("Synthetic model failure"); + return finishStream(); + }, + }); + registerModelRuntimeResolverRevoker(resolver, () => { + revocations++; + }); + const f = fixture({ facades: { resolveModelRuntime: resolver } }); + try { + const consuming = Array.fromAsync(await preparedStream(f)).catch(() => []); + await entered.promise; + if (outcome === "abort") { + const closing = f.owner.close(); + unblock.resolve(); + await closing; + } + await consuming; + assertEquals(revocations, 1); + } finally { + unblock.resolve(); + await f.owner.close(); + } + assertEquals(revocations, 1); + }); + } + + for (const toolPolicy of [{ allow: ["read_file"] }, { deny: ["update_file"] }]) { + it(`applies the declared MCP ${toolPolicy.allow ? "allow" : "deny"} policy to its facade`, async () => { + const f = fixture({ + config: { mcpServers: [{ kind: "veryfront-api", id: "api", toolPolicy }] }, + grant: { ...grant, allowedToolNames: ["update_file"], remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => Promise.resolve([syntheticRemoteTool("update_file")]), + executeTool: () => Promise.reject(new Error("Disallowed tool executed")), + }]]), + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + } finally { + await f.owner.close(); + } + }); + } + + it("accepts 129 granted tools before applying the provider cap", async () => { + const names = Array.from({ length: 129 }, (_, i) => `tool_${String(i).padStart(3, "0")}`); + let visible: string[] = []; + const f = fixture({ + grant: { ...grant, allowedToolNames: names, remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => Promise.resolve(names.map(syntheticRemoteTool)), + executeTool: () => Promise.resolve({ ok: true }), + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(visible.length, 128); + } finally { + await f.owner.close(); + } + }); + + it("keeps an MCP denial scoped to its source when another facade grants the same name", async () => { + const executions: string[] = []; + let calls = 0; + const f = fixture({ + config: { + mcpServers: [{ + kind: "veryfront-api", + id: "restricted", + toolPolicy: { deny: ["update_file"] }, + }], + }, + grant: { + ...grant, + allowedToolNames: ["update_file"], + remoteToolSourceIds: ["restricted", "allowed"], + }, + facades: { + remoteToolSources: new Map(["restricted", "allowed"].map((id) => [id, { + id, + listTools: () => Promise.resolve([syntheticRemoteTool("update_file")]), + executeTool: () => { + executions.push(id); + return Promise.resolve({ ok: true }); + }, + }])), + resolveModelRuntime: () => ({ + ...model, + doStream: () => finishStream(calls++ === 0 ? "update_file" : undefined), + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(executions, ["allowed"]); + } finally { + await f.owner.close(); + } + }); + + it("refuses a selected capability that is actually missing before provider capping", async () => { + const f = fixture({ grant: { ...grant, allowedToolNames: ["missing_tool"] } }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + } finally { + await f.owner.close(); + } + }); + + it("accepts an owner tool's short selector while dispatching its qualified name", async () => { + let visible: string[] = []; + const f = fixture({ + grant: { ...grant, allowedToolNames: ["fetch-paper"], hostToolFacadeIds: ["local"] }, + facades: { + hostTools: new Map([["local", { + "coder--fetch-paper": { + ...syntheticHostTool(), + ownerAgentId: "coder", + shortName: "fetch-paper", + }, + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(visible, ["coder--fetch-paper"]); + } finally { + await f.owner.close(); + } + }); + + const selectorCases: { + name: string; + granted: string[]; + tools: AgentConfig["tools"]; + requested: string[]; + expected: string[]; + }[] = [ + { + name: "qualified grant, short source and request", + granted: ["coder--fetch-paper"], + tools: { "fetch-paper": true }, + requested: ["fetch-paper"], + expected: ["coder--fetch-paper"], + }, + { + name: "qualified grant and short request without a source allowlist", + granted: ["coder--fetch-paper"], + tools: true, + requested: ["fetch-paper"], + expected: ["coder--fetch-paper"], + }, + { + name: "short grant, qualified source and request", + granted: ["fetch-paper"], + tools: { "coder--fetch-paper": true }, + requested: ["coder--fetch-paper"], + expected: ["coder--fetch-paper"], + }, + { + name: "short source denial against a qualified grant", + granted: ["coder--fetch-paper"], + tools: { "coder--fetch-paper": true, "fetch-paper": false }, + requested: ["fetch-paper"], + expected: [], + }, + { + name: "qualified source denial against a short grant", + granted: ["fetch-paper"], + tools: { "fetch-paper": true, "coder--fetch-paper": false }, + requested: ["fetch-paper"], + expected: [], + }, + { + name: "no grant despite matching source and request aliases", + granted: [], + tools: { "fetch-paper": true }, + requested: ["fetch-paper"], + expected: [], + }, + { + name: "another owner's grant with the same short name", + granted: ["other--fetch-paper"], + tools: { "fetch-paper": true }, + requested: ["fetch-paper"], + expected: [], + }, + { + name: "a source allowlist that excludes the granted tool", + granted: ["coder--fetch-paper"], + tools: { another: true }, + requested: ["fetch-paper"], + expected: [], + }, + { + name: "an empty request selector", + granted: ["coder--fetch-paper"], + tools: { "fetch-paper": true }, + requested: [], + expected: [], + }, + ]; + for (const selection of selectorCases) { + it(`normalizes selectors before intersection: ${selection.name}`, async () => { + const state = runtime({ tools: selection.tools }); + const executions: string[] = []; + for (const ownerAgentId of ["coder", "other"]) { + const id = `${ownerAgentId}--fetch-paper`; + state.tools.set(id, { + ...syntheticHostTool(), + id, + type: "function", + ownerAgentId, + shortName: "fetch-paper", + execute: () => { + executions.push(id); + return Promise.resolve({ ok: true }); + }, + }); + } + let visible: string[] = []; + let calls = 0; + const f = fixture({ + load: () => Promise.resolve(state), + grant: { + ...grant, + allowedToolNames: selection.granted, + remoteToolSourceIds: ["api"], + }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => Promise.resolve([syntheticRemoteTool("fetch-paper")]), + executeTool: () => Promise.reject(new Error("Ungranted alias executed")), + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(calls++ === 0 ? visible[0] : undefined); + }, + }), + }, + }); + try { + await Array.fromAsync( + await preparedStream(f, { + agentId: "coder", + allowedToolNames: selection.requested, + }), + ); + assertEquals(visible, selection.expected); + assertEquals(executions, selection.expected); + } finally { + await f.owner.close(); + } + }); + } + + for ( + const [path, success, expectedRefreshes] of [ + ["AGENTS.md", true, 1], + ["skills/example/SKILL.md", true, 1], + ["AGENTS.md", false, 0], + ["readme.txt", true, 0], + ] as const + ) { + it(`refreshes steering after ${path} only on a successful steering change (${success})`, async () => { + let calls = 0; + let refreshes = 0; + const systems: string[] = []; + const f = fixture({ + grant: { + ...grant, + allowedToolNames: ["update_file"], + remoteToolSourceIds: ["api"], + execution: { kind: "ephemeral", projectId: "synthetic-project" }, + }, + facades: { + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => { + refreshes++; + return "Updated synthetic steering"; + }, + }, + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => + Promise.resolve([{ + ...syntheticRemoteTool("update_file"), + parameters: { + type: "object", + properties: { path: { type: "string" }, project_reference: { type: "string" } }, + required: ["project_reference"], + }, + }]), + executeTool: () => Promise.resolve({ success }), + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + systems.push(JSON.stringify((options as ModelRuntimeCallOptions).prompt)); + return finishStream(calls++ === 0 ? "update_file" : undefined, { path }); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(calls, 2); + assertEquals(refreshes, expectedRefreshes); + assertEquals(systems[1]?.includes("Updated synthetic steering"), expectedRefreshes === 1); + } finally { + await f.owner.close(); + } + }); + } + + for ( + const selectedModel of [ + modelId, + "veryfront-cloud/anthropic/claude-sonnet-4-6", + "veryfront-cloud/anthropic/claude-opus-4-7", + ] + ) { + for (const thinking of [{ enabled: false }, { enabled: true, budgetTokens: 4096 }]) { + it(`carries explicit thinking ${thinking.enabled} into ${selectedModel} call data`, async () => { + let captured: ModelRuntimeCallOptions | undefined; + let sourceTransportCalls = 0; + const adaptive = selectedModel.endsWith("claude-opus-4-7") && thinking.enabled; + const f = fixture({ + config: { + thinking: { enabled: !thinking.enabled }, + resolveModelTransport: () => { + sourceTransportCalls++; + throw new Error("Source hook called"); + }, + }, + grant: { + ...grant, + defaultModelId: selectedModel, + models: new Map([[selectedModel, { maxOutputTokens: 8192, providerToolNames: [] }]]), + }, + facades: { + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + captured = options as ModelRuntimeCallOptions; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync( + await preparedStream(f, { agentId: "coder", thinking: { ...thinking } as JsonValue }), + ); + assert(captured); + assertEquals(sourceTransportCalls, 0); + assertEquals(captured.headers, undefined); + assertEquals(captured.reasoning, adaptive ? undefined : thinking); + assertEquals( + captured.providerOptions, + adaptive + ? { + anthropic: { + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "high" }, + }, + } + : undefined, + ); + assertPersistedModelOptions({ + identity: { binding, sequence: 1 }, + mode: "stream", + model: { + id: selectedModel, + modelId: selectedModel, + provider: selectedModel.includes("anthropic") ? "anthropic" : "openai", + }, + options: captured, + }); + } finally { + await f.owner.close(); + } + }); + } + } +}); + +describe("executor runtime preparation artifact and materialization regressions", () => { + const researchRequest = + "/research Research synthetic widgets and save the report to the project."; + const reportPath = "research/synthetic-widgets/report.md"; + const mirrorPath = "research/synthetic-widgets/runs/synthetic-run.report.md"; + const reportContent = "# Synthetic widgets\n\nSynthetic research findings."; + + for ( + const existing of ["none", "returned collision", "thrown collision", "ungranted retry"] as const + ) { + it(`normalizes research writes and mirrors the run report with ${existing}`, async () => { + const files = new Map(); + if (existing !== "none") { + files.set(reportPath, "Previous report"); + files.set(mirrorPath, "Previous run report"); + } + const calls: Array<{ toolName: string; args: Record }> = []; + let modelCalls = 0; + const f = fixture({ + grant: { + ...grant, + allowedToolNames: existing === "ungranted retry" + ? ["create_file"] + : ["create_file", "update_file"], + remoteToolSourceIds: ["api"], + execution: { + kind: "canonical", + projectId: "synthetic-project", + conversationId: "synthetic-conversation", + runId: "synthetic-run", + messageId: "synthetic-message", + providerReplay: "disabled", + }, + }, + config: { + mcpServers: [{ + kind: "veryfront-api", + id: "api", + toolPolicy: { allow: ["create_file", "update_file"] }, + }], + }, + facades: { + latestConversationUserText: () => Promise.resolve(researchRequest), + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => "Synthetic instructions", + }, + publishParentRunEvents: () => Promise.resolve(), + toolExposureCheckpoint: { persist: () => Promise.resolve() }, + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => + Promise.resolve(["create_file", "update_file"].map((name) => ({ + ...syntheticRemoteTool(name), + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + project_reference: { type: "string" }, + }, + required: ["path", "content", "project_reference"], + }, + }))), + executeTool: (toolName, args) => { + calls.push({ toolName, args }); + assertEquals(args.project_reference, "synthetic-project"); + assertEquals(typeof args.path, "string"); + if (toolName === "create_file" && files.has(String(args.path))) { + const error = { isError: true, message: `File already exists: ${args.path}` }; + return existing === "thrown collision" + ? Promise.reject(error) + : Promise.resolve(error); + } + files.set(String(args.path), String(args.content)); + return Promise.resolve({ success: true, path: args.path }); + }, + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: () => + finishStream(modelCalls++ === 0 ? "create_file" : undefined, { + path: "report.md", + content: reportContent, + }), + }), + }, + }); + try { + const frames = await Array.fromAsync( + await preparedStream(f, { agentId: "coder" }, researchRequest), + ); + assertEquals(frames.at(-1), { type: "complete" }); + const expectedCalls = existing === "none" + ? [["create_file", reportPath], ["create_file", mirrorPath]] + : existing === "ungranted retry" + ? [["create_file", reportPath]] + : [ + ["create_file", reportPath], + ["update_file", reportPath], + ["create_file", mirrorPath], + ["update_file", mirrorPath], + ]; + assertEquals(calls.map(({ toolName, args }) => [toolName, args.path]), expectedCalls); + assertEquals([...files], [ + [reportPath, existing === "ungranted retry" ? "Previous report" : reportContent], + [mirrorPath, existing === "ungranted retry" ? "Previous run report" : reportContent], + ]); + } finally { + await f.owner.close(); + } + }); + } + + it("refuses a selected research retry companion denied by MCP", async () => { + let executions = 0; + const f = fixture({ + config: { + mcpServers: [{ kind: "veryfront-api", id: "api", toolPolicy: { deny: ["update_file"] } }], + }, + grant: { + ...grant, + allowedToolNames: ["create_file", "update_file"], + remoteToolSourceIds: ["api"], + }, + facades: { + latestConversationUserText: () => Promise.resolve(researchRequest), + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => Promise.resolve(["create_file", "update_file"].map(syntheticRemoteTool)), + executeTool: () => { + executions++; + return Promise.resolve({ ok: true }); + }, + }]]), + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(executions, 0); + } finally { + await f.owner.close(); + } + }); + + for ( + const [name, field, value] of [ + ["missing execute", "execute", undefined], + ["missing schema", "inputSchema", undefined], + ["unsupported raw schema", "inputSchema", { type: "object", properties: {} }], + ["malformed schema", "inputSchema", "unsupported-schema"], + ["missing description", "description", undefined], + ] as const + ) { + it(`refuses a selected host descriptor with ${name}`, async () => { + let modelCalls = 0; + const descriptor: HostToolDefinition = { ...syntheticHostTool(), [field]: value }; + const f = fixture({ + grant: { ...grant, allowedToolNames: ["work"], hostToolFacadeIds: ["local"] }, + facades: { + hostTools: new Map([["local", { work: descriptor }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: () => { + modelCalls++; + return finishStream(); + }, + }), + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(modelCalls, 0); + assertEquals(f.cleanups, 1); + } finally { + await f.owner.close(); + } + }); + } + + for (const malformedLast of [false, true]) { + it(`validates 129 materializable host tools before provider capping (${malformedLast})`, async () => { + const names = Array.from({ length: 129 }, (_, i) => `work_${String(i).padStart(3, "0")}`); + const tools = Object.fromEntries(names.map((name, i) => [name, { + ...syntheticHostTool(), + ...(i % 2 === 0 + ? { inputSchema: undefined, inputSchemaJson: { type: "object" as const, properties: {} } } + : {}), + ...(malformedLast && i === 128 ? { execute: undefined } : {}), + }])); + let visible: string[] = []; + const f = fixture({ + grant: { ...grant, allowedToolNames: names, hostToolFacadeIds: ["local"] }, + facades: { + hostTools: new Map([["local", tools]]), + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(); + }, + }), + }, + }); + try { + if (malformedLast) { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + } else { + await Array.fromAsync(await preparedStream(f)); + assertEquals(visible, names.slice(0, 128)); + } + } finally { + await f.owner.close(); + } + }); + } +}); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts new file mode 100644 index 0000000000..ee6a3db08d --- /dev/null +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -0,0 +1,558 @@ +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { VERYFRONT_CLOUD_MODEL_PREFIX } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; +import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; +import { isToolVisibleTo } from "#veryfront/tool"; +import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; +import type { AgentSystem } from "../types.ts"; +import { + type AgentModelRuntimeResolver, + registerModelRuntimeResolverRevoker, + revokeModelRuntimeResolver, +} from "../runtime/model-transport.ts"; +import { wrapRemoteToolSourceWithMcpPolicy } from "../mcp-tool-policy.ts"; +import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; +import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import type { ExecutorDiscovery } from "./executor-discovery.ts"; +import { + type ExecutorDiscoverySource, + getExecutorAgentDescribeResultSchema, + getExecutorDiscoverySourceSchema, +} from "./executor-discovery-schema.ts"; +import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; +import { executorAgentFailureCode, executorAgentJson } from "./executor-agent-schema.ts"; +import { createExecutorAgentOperations } from "./executor-agent-bridge.ts"; +import { createHostedChatRuntimeDataStream } from "./chat-runtime-agent-adapter.ts"; +import { + createPreparedHostedRuntimeAgent, + incrementSteeringRevision, + type PreparedHostedRuntimeAgentOptions, +} from "./default-chat-runtime.ts"; +import { + prepareFacadedHostedChatRuntimeToolAssembly, + resolveOwnerScopedToolNames, +} from "./chat-runtime-tool-assembly.ts"; +import type { + HostedChatRuntimeCreationOptions, + HostedChatRuntimeProjectSteering, +} from "./chat-runtime-contract.ts"; +import type { RuntimeAgentThinkingConfig } from "../runtime/agent-definition.ts"; +import { resolveRuntimeSkillSelectorForAgent } from "../runtime/skill-metadata.ts"; +import { runWithProjectAgentRuntime } from "../project/agent-runtime.ts"; +import { + applyDefaultResearchArtifactPath, + shouldRetryCreateResearchArtifactAsUpdate, +} from "../artifacts/default-research-artifact-support.ts"; +import { buildInteractiveVeryfrontCloudRuntimeInstructions } from "./cloud-runtime-system-messages.ts"; +import { + type ExecutorRuntimeGrantData, + ExecutorRuntimePreparationError, + type ExecutorRuntimePrepareRequest, + getExecutorRuntimeGrantDataSchema, + getExecutorRuntimePrepareRequestSchema, + parseRuntimePreparationData, +} from "./executor-runtime-prepare-schema.ts"; + +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(): Promise | AgentSystem; + }; + latestConversationUserText?: () => 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; + discovery: ExecutorDiscovery; + grant?: ExecutorRuntimePreparationGrant; + 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) || + new Set(parsed.models.map((model) => model.id)).size !== parsed.models.length + ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + return parsed; +} +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 granted.filter((name) => + (source === undefined || source === true || source.includes(name)) && + (requested === undefined || requested.includes(name)) && !denied.includes(name) + ); +} + +/** One allocation's static preparation/stream dispatcher. Metadata never installs execution authority. */ +export function createExecutorRuntimePreparation(input: Options) { + const grant = snapshotGrant(input.grant); + const binding = parseRuntimePreparationData(getExecutorBindingSchema(), input.binding); + const source = parseRuntimePreparationData(getExecutorDiscoverySourceSchema(), input.source); + const facades: ExecutorRuntimeFacades = { + ...input.facades, + hostTools: new Map(input.facades.hostTools), + remoteToolSources: new Map(input.facades.remoteToolSources), + }; + 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; + const settled = Promise.withResolvers(); + void settled.promise.catch(() => {}); + const assertActive = () => { + if (lifetime.signal.aborted || input.discovery.signal.aborted) { + refuse("EXECUTOR_RUNTIME_CLOSED"); + } + }; + const release = () => { + cleanup ??= Promise.resolve().then(async () => { + // Startup can still reserve producer work. Join both before releasing + // facades, without joining the stream handler that calls this cleanup. + await startup?.catch(() => {}); + await producerCompletion; + if (resourcesStarted) await facades.cleanup(); + }); + return cleanup; + }; + function close(): Promise { + if (closing) return closing; + closing = Promise.resolve().then(async () => { + await preparation?.catch(() => {}); + let failed = false; + try { + await release(); + } catch { + failed = true; + } + try { + await input.discovery.close(); + } catch { + failed = true; + } + preparedOperations = undefined; + if (failed) refuse("EXECUTOR_RUNTIME_CLEANUP_FAILED"); + }); + void closing.then(settled.resolve, settled.reject); + lifetime.abort(); + input.discovery.signal.removeEventListener("abort", onDiscoveryAbort); + return closing; + } + const onDiscoveryAbort = () => { + void close().catch(() => {}); + }; + input.discovery.signal.addEventListener("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 (const id of effective.hostToolFacadeIds) { + if (!facades.hostTools.has(id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + for (const id of effective.remoteToolSourceIds) { + if (!facades.remoteToolSources.has(id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + for (const server of definition.mcpServers ?? []) { + if (!effective.remoteToolSourceIds.includes(server.id ?? server.kind)) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + } + if ( + (effective.execution.projectId !== null || + effective.requiredCapabilities?.includes("project-steering")) && + (typeof facades.projectSteering?.prepare !== "function" || + typeof facades.projectSteering?.refresh !== "function") + ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + if ( + effective.requiredCapabilities?.includes("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 = input.discovery.operations.get("agent.describe"); + if (operation?.mode !== "unary") refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + const described = getExecutorAgentDescribeResultSchema().parse( + await operation.handle({ agentId: request.agentId }, context), + ); + 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 = grant.models.find((model) => model.id === modelId); + if ( + !modelGrant || (request.maxSteps !== undefined && request.maxSteps > grant.maxSteps) || + (request.maxOutputTokens !== undefined && + request.maxOutputTokens > modelGrant.maxOutputTokens) + ) 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!); + const localTools: HostToolSet = Object.fromEntries( + [...runtime.tools].filter(([id, value]) => + !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId: definition.id }) + ), + ); + for (const id of grant.hostToolFacadeIds) { + Object.assign(localTools, facades.hostTools.get(id)); + } + const normalizeToolNames = (names: readonly string[]) => [ + ...resolveOwnerScopedToolNames({ + toolNames: names, + agentId: definition.id, + localTools, + })!, + ]; + const deniedToolNames = [ + ...new Set([ + ...definition.deniedTools ?? [], + ...normalizeToolNames(definition.deniedTools ?? []), + ]), + ]; + const allowedToolNames = intersectNames( + normalizeToolNames(grant.allowedToolNames), + Array.isArray(definition.tools) ? normalizeToolNames(definition.tools) : definition.tools, + request.allowedToolNames === undefined + ? undefined + : normalizeToolNames(request.allowedToolNames), + deniedToolNames, + ); + const providerToolNames = intersectNames( + modelGrant.providerToolNames, + definition.providerTools, + request.providerToolNames, + definition.deniedTools, + ); + const resolveModelRuntime: AgentModelRuntimeResolver = (id) => { + assertActive(); + if (!grant.models.some((entry) => entry.id === id)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + return facades.resolveModelRuntime(id) ?? refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + }; + registerModelRuntimeResolverRevoker( + resolveModelRuntime, + () => revokeModelRuntimeResolver(facades.resolveModelRuntime), + ); + resolveModelRuntime(modelId); + const execution = grant.execution; + resourcesStarted = true; + const steering = facades.projectSteering + ? await facades.projectSteering.prepare({ + definition, + projectId: execution.projectId, + branchId: execution.branchId, + signal: lifetime.signal, + }) + : undefined; + 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, + }); + const taskContext = { + ...execution, + steeringRevision: 0, + agentId: definition.id, + model: modelId, + availableSkillIds: skills.allowedSkillIds, + ...(execution.kind === "canonical" + ? { parentRunId: execution.runId, parentMessageId: execution.messageId } + : {}), + }; + 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: allowedToolNames.includes("load_skill") ? skills.definitions : [], + environmentContext: steering.environmentContext, + availableToolNames: allowedToolNames, + }) + : definition.system ?? definition.instructions), + temperature: request.temperature ?? definition.temperature, + thinking: request.thinking ?? definition.thinking, + maxSteps: Math.min( + request.maxSteps ?? grant.maxSteps, + definition.maxSteps ?? grant.maxSteps, + grant.maxSteps, + ), + maxOutputTokens: request.maxOutputTokens ?? modelGrant.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, + } + : {}), + } + : {}), + }; + const toolAssembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + taskContext, + instructions: options.instructions, + localTools, + sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, + hostToolPolicy: { allow: allowedToolNames }, + allowedToolNames, + deniedToolNames, + allowedProviderToolNames: providerToolNames, + sourceProviderToolNames: definition.providerTools, + prepareRemoteToolInput: ({ toolName, toolInput }) => + applyDefaultResearchArtifactPath(toolName, toolInput, taskContext), + shouldRetryWithRemoteTool: ({ toolName, toolInput, error }) => + shouldRetryCreateResearchArtifactAsUpdate({ + toolName, + toolInput, + taskContext, + error, + }), + remoteToolSources: grant.remoteToolSourceIds.map((id) => + (definition.mcpServers ?? []).filter((server) => (server.id ?? server.kind) === id) + .reduce( + (source, server) => wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy), + facades.remoteToolSources.get(id)!, + ) + ), + onSteeringMutation: (mutation) => { + if (mutation.instructionsChanged || mutation.skillsChanged) { + incrementSteeringRevision(taskContext); + } + }, + loadLatestConversationUserText: facades.latestConversationUserText, + }); + assertActive(); + for (const name of toolAssembly.normalizedAllowedToolNames ?? []) { + if (!toolAssembly.authorizedToolNames.includes(name)) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + } + const runtimeAgent = runWithProjectAgentRuntime( + runtime, + () => + createPreparedHostedRuntimeAgent({ + options, + taskContext, + toolAssembly, + modelId, + sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, + refreshSystem: facades.projectSteering?.refresh.bind(facades.projectSteering), + }, { + resolveModelRuntime, + preserveToolCatalog: true, + onStreamCompletion: (completion) => { + producerCompletion = completion; + input.discovery.retainRuntimeTask(completion); + }, + }), + ); + assertActive(); + const preparedRuntimeHandle = crypto.randomUUID(); + preparedOperations = createExecutorAgentOperations({ + preparedRuntimeHandle, + startStream: (streamInput) => { + startup = Promise.resolve().then(() => { + assertActive(); + return createHostedChatRuntimeDataStream({ + 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, + }, 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, + ); + executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + const cancel = () => { + void close().catch(() => {}); + }; + context.signal.addEventListener("abort", cancel, { once: true }); + preparation = prepare(request, { + ...context, + signal: AbortSignal.any([context.signal, lifetime.signal]), + }); + if (context.signal.aborted) cancel(); + try { + return await preparation; + } finally { + context.signal.removeEventListener("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?.get("agent.stream"); + if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); + yield* operation.handle(value, { + ...context, + signal: AbortSignal.any([context.signal, lifetime.signal]), + }); + }, + }); + return { operations, close, settled: settled.promise, signal: lifetime.signal }; +} diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 2a7d397684..c5be7f67a0 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1623,6 +1623,12 @@ type RuntimeStepState = { /** @internal Framework-only AgentRuntime construction options. */ export type AgentRuntimeInternalOptions = { resolveModelRuntime?: AgentModelRuntimeResolver; + /** Preserve the factory caller's prevalidated catalog without implicit tools or delegates. */ + preserveToolCatalog?: boolean; + /** Call controls for private models, independent of source transport hooks. */ + modelCallThinking?: RuntimeReasoningOption & { enabled: boolean }; + /** Observe original producer settlement before it starts, including its full cleanup. */ + onStreamCompletion?: (completion: Promise) => void; }; type AgentRuntimeGenerateArgs = [ @@ -1703,6 +1709,8 @@ export function streamWithAgentRuntimeDispatch( /** Implement agent runtime. */ export class AgentRuntime { #modelResolverState: AgentRuntimeModelResolverState; + #modelCallThinking: AgentRuntimeInternalOptions["modelCallThinking"]; + #onStreamCompletion: AgentRuntimeInternalOptions["onStreamCompletion"]; private id: string; private config: AgentConfig; private memory: Memory; @@ -1713,6 +1721,8 @@ export class AgentRuntime { config: AgentConfig, internalOptions: AgentRuntimeInternalOptions = {}, ) { + this.#modelCallThinking = internalOptions.modelCallThinking; + this.#onStreamCompletion = internalOptions.onStreamCompletion; this.#modelResolverState = internalOptions.resolveModelRuntime ? { status: "available", resolver: internalOptions.resolveModelRuntime } : { status: "absent" }; @@ -2075,6 +2085,7 @@ export class AgentRuntime { modelOverride, mode, resolveModelRuntime, + modelCallThinking: this.#modelCallThinking, }), ...(resolveModelRuntime ? { resolveModelRuntime } : {}), }; @@ -2454,6 +2465,8 @@ export class AgentRuntime { // an unhandled rejection under Deno (#2334). let inFlight: Promise | undefined; + const completion = Promise.withResolvers(); + this.#onStreamCompletion?.(completion.promise); const runtimeStream = new IntrinsicReadableStream({ start: async (controller) => { try { @@ -2577,7 +2590,11 @@ export class AgentRuntime { sendSSE(controller, encoder, resolveRuntimeExecutionErrorEvent(error)); closeSSEStream(controller); } finally { - abortScope.dispose(); + try { + abortScope.dispose(); + } finally { + completion.resolve(); + } } }, cancel(reason) { diff --git a/src/agent/runtime/model-transport.test.ts b/src/agent/runtime/model-transport.test.ts index 16451efcd8..e7914c6990 100644 --- a/src/agent/runtime/model-transport.test.ts +++ b/src/agent/runtime/model-transport.test.ts @@ -132,6 +132,31 @@ describe("resolveAgentModelTransport", () => { assertStrictEquals(transport.languageModel, projectModel); }); + for (const mode of ["generate", "stream"] as const) { + it(`keeps private thinking controls independent of source transport in ${mode}`, async () => { + const frameworkModel = createModel("veryfront-cloud/openai/gpt-5.4"); + const transport = await resolveAgentModelTransport({ + agentId: "synthetic-agent", + config: { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + resolveModelTransport: () => { + throw new Error("Source transport called"); + }, + }, + context: undefined, + modelOverride: undefined, + mode, + resolveModelRuntime: () => frameworkModel, + modelCallThinking: { enabled: false }, + }); + assertStrictEquals(transport.languageModel, frameworkModel); + assertEquals(transport.headers, undefined); + assertEquals(transport.providerOptions, undefined); + assertEquals(transport.reasoning, { enabled: false }); + }); + } + it("defaults reasoning for non-Anthropic thinking-capable Veryfront Cloud models", async () => { const hostModel = createModel("veryfront-cloud/google-ai-studio/gemini-2.5-pro"); const config: AgentConfig = { diff --git a/src/agent/runtime/model-transport.ts b/src/agent/runtime/model-transport.ts index 9bc194f543..3556c03e3c 100644 --- a/src/agent/runtime/model-transport.ts +++ b/src/agent/runtime/model-transport.ts @@ -16,6 +16,7 @@ import { import { resolveVeryfrontCloudModelThinking, resolveVeryfrontCloudReasoningOption, + resolveVeryfrontCloudThinkingProviderOptions, tryGetVeryfrontCloudProviderFromModelId, VERYFRONT_CLOUD_MODEL_PREFIX, } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; @@ -181,6 +182,7 @@ export interface ResolveAgentModelTransportInput { modelOverride: string | undefined; mode: "generate" | "stream"; resolveModelRuntime?: AgentModelRuntimeResolver; + modelCallThinking?: RuntimeReasoningOption & { enabled: boolean }; } function resolveReasoningWithDefaults( @@ -223,10 +225,21 @@ export async function resolveAgentModelTransport( mode: input.mode, }); - const providerOptions = resolveProviderOptionsWithDefaults( + const privateThinking = privatelyResolvedModel + ? input.modelCallThinking ?? resolveVeryfrontCloudModelThinking(resolvedModelString) + : undefined; + const privateReasoning = resolveVeryfrontCloudReasoningOption( resolvedModelString, - transport?.providerOptions, + privateThinking, ); + // Private managed calls carry audited neutral controls. Only adaptive + // Anthropic thinking needs native call data; legacy native temperature + // overrides would replace the broker's persisted neutral input. + const providerOptions = privatelyResolvedModel + ? privateThinking?.enabled && privateReasoning === undefined + ? resolveVeryfrontCloudThinkingProviderOptions(resolvedModelString, privateThinking) + : undefined + : resolveProviderOptionsWithDefaults(resolvedModelString, transport?.providerOptions); const languageModel = privatelyResolvedModel ?? transport?.model ?? resolveModel(resolvedModelString); const providerOptionKey = resolveModelProviderOptionKey(resolvedModelString, languageModel); @@ -238,7 +251,7 @@ export async function resolveAgentModelTransport( ...(providerOptionKey ? { providerOptionKey } : {}), headers: transport?.headers, providerOptions, - reasoning: resolveReasoningWithDefaults( + reasoning: privatelyResolvedModel ? privateReasoning : resolveReasoningWithDefaults( resolvedModelString, transport?.reasoning, providerOptions, diff --git a/src/agent/runtime/runtime-stream-cancel.test.ts b/src/agent/runtime/runtime-stream-cancel.test.ts index 0156c522d1..d5bae1892f 100644 --- a/src/agent/runtime/runtime-stream-cancel.test.ts +++ b/src/agent/runtime/runtime-stream-cancel.test.ts @@ -8,6 +8,7 @@ import { defineSchema } from "#veryfront/schemas/index.ts"; import type { ModelRuntime } from "#veryfront/provider"; import { agent } from "../index.ts"; import { AgentRuntime } from "./index.ts"; +import type { RuntimeToolFilterConfig } from "./runtime-tool-config.ts"; import { scriptedModel } from "./model-runtime.test-helpers.ts"; import { type AgentModelRuntimeResolver, @@ -93,6 +94,53 @@ function settleAbortedRun(): Promise { } describe("agent runtime stream cancellation (#2334)", () => { + it("reserves completion before producer work and joins failure finalization after cancellation", async () => { + const entered = Promise.withResolvers(); + const unblock = Promise.withResolvers(); + let completion: Promise | undefined; + let completed = false; + const config: RuntimeToolFilterConfig = { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + __vfProviderReplayCheckpointTurnFailed: async () => { + entered.resolve(); + await unblock.promise; + }, + }; + const runtime = new AgentRuntime("synthetic-runtime", config, { + resolveModelRuntime: () => ({ + provider: "openai", + modelId: "gpt-5.4", + doGenerate: () => Promise.reject(new Error("Unexpected generate")), + doStream: () => { + assert(completion, "Completion must be registered before producer work"); + return Promise.reject(new Error("Synthetic stream failure")); + }, + }), + onStreamCompletion: (pending) => { + completion = pending; + void pending.then(() => { + completed = true; + }); + }, + }); + const stream = await runtime.stream([{ + id: "synthetic-message", + role: "user", + parts: [{ type: "text", text: "Synthetic input" }], + }]); + try { + await entered.promise; + await stream.cancel(); + assert(completion); + assertEquals(completed, false); + } finally { + unblock.resolve(); + await completion; + } + assertEquals(completed, true); + }); + it("revokes run-scoped model authority when generation starts aborted", async () => { const model = scriptedModel([{ text: "must not run" }], { modelId: "veryfront-cloud/openai/pre-aborted-model", From dc7bd13abd88d3e8ecdf207acc144678adc90900 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 08:57:55 +0200 Subject: [PATCH 037/194] Use framework aliases across executor discovery module boundaries --- src/agent/hosted/executor-discovery-node.ts | 2 +- src/agent/hosted/executor-discovery-schema.ts | 2 +- src/agent/hosted/executor-discovery.test.ts | 2 +- src/agent/hosted/executor-discovery.ts | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/agent/hosted/executor-discovery-node.ts b/src/agent/hosted/executor-discovery-node.ts index 038f0d3b87..e405612c5b 100644 --- a/src/agent/hosted/executor-discovery-node.ts +++ b/src/agent/hosted/executor-discovery-node.ts @@ -4,7 +4,7 @@ import { bindExecutorDiscoveryRoots } from "#veryfront/agent/hosted/executor-dis import { clearRegistryScope } from "#veryfront/registry/project-scoped-registry-manager.ts"; import { tryGetRegistryScopeId } from "#veryfront/cache/cache-key-builder.ts"; import { clearTranspileCache } from "#veryfront/discovery/transpiler.ts"; -import { discoverProjectAgentRuntime } from "../project/agent-runtime.ts"; +import { discoverProjectAgentRuntime } from "#veryfront/agent/project/agent-runtime.ts"; import { nodeAdapter } from "#veryfront/platform/adapters/node.ts"; import type { ExecutorDiscoveryBackend } from "./executor-discovery.ts"; import { ExecutorDiscoveryError } from "./executor-discovery-schema.ts"; diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts index aceb1fcd55..f79ec8f7ed 100644 --- a/src/agent/hosted/executor-discovery-schema.ts +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -1,7 +1,7 @@ import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; import { defineError, snapshotVeryfrontError, VeryfrontError } from "#veryfront/errors/types.ts"; -import { getRuntimeAgentMarkdownDefinitionSchema } from "../runtime/agent-definition.ts"; +import { getRuntimeAgentMarkdownDefinitionSchema } from "#veryfront/agent/runtime/agent-definition.ts"; import { executorAgentJson } from "./executor-agent-schema.ts"; import { hasControlCharacters, isWellFormedUtf16 } from "#veryfront/skill/string-safety.ts"; diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index 823e5aefe1..7ca1702cbf 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -5,7 +5,7 @@ import { CONFIG_INVALID, CONFIG_PARSE_ERROR, CONFIG_VALIDATION_FAILED } from "#v import type { JsonValue } from "#veryfront/schemas/index.ts"; import { agent } from "../factory.ts"; import type { Agent } from "../types.ts"; -import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; +import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; import { createRuntimeAgentFromMarkdownDefinition } from "../runtime/agent-markdown-adapter.ts"; import { createExecutorDiscovery, type ExecutorDiscoveryBackend } from "./executor-discovery.ts"; import { diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 45ed98e489..523e533ea2 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -3,8 +3,8 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ProjectAgentRuntimeAgentSource, ProjectAgentRuntimeDiscovery, -} from "../project/agent-runtime.ts"; -import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +} from "#veryfront/agent/project/agent-runtime.ts"; +import type { RuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; import { @@ -85,7 +85,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut let runtime: ProjectAgentRuntimeDiscovery | undefined; let closing: Promise | undefined; const definitions = new Map(); - const helpers = () => import("../project/agent-runtime.ts"); + const helpers = () => import("#veryfront/agent/project/agent-runtime.ts"); function assertActive() { if (lifetime.signal.aborted) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLOSED"); @@ -161,7 +161,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut definition = { ...projected, id: agentId }; } else { if (agentSource === "code") throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); - const files = await import("../runtime/agent-definition-files.ts"); + const files = await import("#veryfront/agent/runtime/agent-definition-files.ts"); const lookup = { baseDir: projectDir, id: agentId }; if (!files.resolveRuntimeAgentDefinitionsDirInputSchema.safeParse(lookup).success) { throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); @@ -177,7 +177,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); } const { parseRuntimeAgentMarkdownDefinition } = await import( - "../runtime/agent-definition.ts" + "#veryfront/agent/runtime/agent-definition.ts" ); definition = parseRuntimeAgentMarkdownDefinition({ id: agentId, From ad416522111d52d640445539e4dfc66941bb91cd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 11:13:13 +0200 Subject: [PATCH 038/194] Retain facade cleanup ownership before model resolution --- .../hosted/executor-runtime-prepare.test.ts | 37 +++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 3 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 9f271c24fd..b375fedb5e 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -269,6 +269,43 @@ describe("executor runtime preparation", () => { } }); + for (const outcome of ["throws", "returns no model"] as const) { + it(`cleans reserved facade resources when model resolution ${outcome}`, async () => { + let retained = false; + let cleanups = 0; + const f = fixture({ + facades: { + resolveModelRuntime: () => { + retained = true; + if (outcome === "throws") { + throw PERMISSION_DENIED.create({ detail: "synthetic-private-diagnostic" }); + } + return undefined; + }, + cleanup: () => { + assert(retained); + retained = false; + cleanups++; + return Promise.resolve(); + }, + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: outcome === "throws" + ? "PERMISSION_DENIED" + : "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(retained, false); + assertEquals(cleanups, 1); + } finally { + await f.owner.close(); + } + assertEquals(cleanups, 1); + }); + } + it("rejects source, mode, and authority fields from wire preparation", async () => { const values: JsonValue[] = [{ agentId: "coder", source }, { agentId: "coder", diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index ee6a3db08d..13f4408d6d 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -316,9 +316,10 @@ export function createExecutorRuntimePreparation(input: Options) { resolveModelRuntime, () => revokeModelRuntimeResolver(facades.resolveModelRuntime), ); + // The first facade call can reserve resources before throwing. + resourcesStarted = true; resolveModelRuntime(modelId); const execution = grant.execution; - resourcesStarted = true; const steering = facades.projectSteering ? await facades.projectSteering.prepare({ definition, From 485ce52a6071231a9d34dc61b0797f28e0a79cb1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 12:03:53 +0200 Subject: [PATCH 039/194] Use framework aliases in executor runtime preparation --- .../hosted/executor-runtime-prepare-schema.ts | 2 +- src/agent/hosted/executor-runtime-prepare.ts | 51 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare-schema.ts b/src/agent/hosted/executor-runtime-prepare-schema.ts index 143f358d15..c5695c9823 100644 --- a/src/agent/hosted/executor-runtime-prepare-schema.ts +++ b/src/agent/hosted/executor-runtime-prepare-schema.ts @@ -1,7 +1,7 @@ import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; import { defineError, VeryfrontError } from "#veryfront/errors/types.ts"; -import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; +import { getExecutorDiscoveryIdSchema } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; const failureStatus = { EXECUTOR_RUNTIME_INVALID_INPUT: 400, diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 13f4408d6d..d59e69456e 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -3,47 +3,56 @@ import { VERYFRONT_CLOUD_MODEL_PREFIX } from "#veryfront/provider/veryfront-clou import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; import { isToolVisibleTo } from "#veryfront/tool"; import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; -import type { AgentSystem } from "../types.ts"; +import type { AgentSystem } from "#veryfront/agent/types.ts"; import { type AgentModelRuntimeResolver, registerModelRuntimeResolverRevoker, revokeModelRuntimeResolver, -} from "../runtime/model-transport.ts"; -import { wrapRemoteToolSourceWithMcpPolicy } from "../mcp-tool-policy.ts"; -import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; -import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; -import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; -import type { ExecutorDiscovery } from "./executor-discovery.ts"; +} 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 { ExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; import { type ExecutorDiscoverySource, getExecutorAgentDescribeResultSchema, getExecutorDiscoverySourceSchema, -} from "./executor-discovery-schema.ts"; -import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; -import { executorAgentFailureCode, executorAgentJson } from "./executor-agent-schema.ts"; -import { createExecutorAgentOperations } from "./executor-agent-bridge.ts"; -import { createHostedChatRuntimeDataStream } from "./chat-runtime-agent-adapter.ts"; +} from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { verifyHostedRuntimeSourceBinding } from "#veryfront/agent/hosted/runtime-source-binding.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, -} from "./default-chat-runtime.ts"; +} from "#veryfront/agent/hosted/default-chat-runtime.ts"; import { prepareFacadedHostedChatRuntimeToolAssembly, resolveOwnerScopedToolNames, -} from "./chat-runtime-tool-assembly.ts"; +} from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; import type { HostedChatRuntimeCreationOptions, HostedChatRuntimeProjectSteering, -} from "./chat-runtime-contract.ts"; -import type { RuntimeAgentThinkingConfig } from "../runtime/agent-definition.ts"; -import { resolveRuntimeSkillSelectorForAgent } from "../runtime/skill-metadata.ts"; -import { runWithProjectAgentRuntime } from "../project/agent-runtime.ts"; +} 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 "../artifacts/default-research-artifact-support.ts"; -import { buildInteractiveVeryfrontCloudRuntimeInstructions } from "./cloud-runtime-system-messages.ts"; +} from "#veryfront/agent/artifacts/default-research-artifact-support.ts"; +import { buildInteractiveVeryfrontCloudRuntimeInstructions } from "#veryfront/agent/hosted/cloud-runtime-system-messages.ts"; import { type ExecutorRuntimeGrantData, ExecutorRuntimePreparationError, @@ -51,7 +60,7 @@ import { getExecutorRuntimeGrantDataSchema, getExecutorRuntimePrepareRequestSchema, parseRuntimePreparationData, -} from "./executor-runtime-prepare-schema.ts"; +} from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; type CreationOptions = HostedChatRuntimeCreationOptions< RuntimeAgentMarkdownDefinition, From 52c7e28c837806619bc465a250862862cd67ec87 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 12:22:34 +0200 Subject: [PATCH 040/194] fix(agent): retain granted Markdown delegate tools during preparation --- .../hosted/executor-runtime-prepare.test.ts | 78 +++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 12 ++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index b375fedb5e..20f946eab9 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -6,6 +6,8 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import type { AgentConfig } from "../types.ts"; +import { parseRuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; +import { createRuntimeAgentFromMarkdownDefinition } from "#veryfront/agent/runtime/agent-markdown-adapter.ts"; import type { HostToolDefinition, ToolDefinition } from "#veryfront/tool"; import { registerModelRuntimeResolverRevoker } from "../runtime/model-transport.ts"; import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; @@ -539,6 +541,82 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + for ( + const selection of [ + { name: "declared delegate", expected: ["read_file", "agent_writer"] }, + { name: "narrowed request", requested: ["read_file"], expected: ["read_file"] }, + { name: "empty request", requested: [], expected: [] }, + { name: "denied delegate", denied: ["agent_writer"], expected: ["read_file"] }, + { name: "missing grant", granted: [], expected: [] }, + ] + ) { + it(`preserves Markdown delegate bindings within authority: ${selection.name}`, async () => { + const definition = parseRuntimeAgentMarkdownDefinition({ + id: "coder", + content: `--- +tools: [read_file] +delegates: [writer, ungranted] +denied-tools: ${JSON.stringify(selection.denied ?? [])} +--- +Synthetic source instructions.`, + }); + const state = runtime(); + state.agents.set("coder", createRuntimeAgentFromMarkdownDefinition(definition)); + const executions: string[] = []; + let visible: string[] = []; + let calls = 0; + const f = fixture({ + load: () => Promise.resolve(state), + grant: { + ...grant, + allowedToolNames: selection.granted ?? ["read_file", "agent_writer", "agent_undeclared"], + hostToolFacadeIds: ["delegates"], + }, + facades: { + hostTools: new Map([[ + "delegates", + Object.fromEntries( + ["read_file", "agent_writer", "agent_ungranted", "agent_undeclared"].map((name) => [ + name, + { + ...syntheticHostTool(), + execute: () => { + executions.push(name); + return { ok: true }; + }, + }, + ]), + ), + ]]), + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream( + calls++ === 0 ? visible.find((name) => name === "agent_writer") : undefined, + ); + }, + }), + }, + }); + try { + await Array.fromAsync( + await preparedStream(f, { + agentId: "coder", + ...(selection.requested === undefined ? {} : { allowedToolNames: selection.requested }), + }), + ); + assertEquals([...visible].sort(), [...selection.expected].sort()); + assertEquals( + executions, + selection.expected.some((name) => name === "agent_writer") ? ["agent_writer"] : [], + ); + } finally { + await f.owner.close(); + } + }); + } + it("retains discovery resources during preparation after upstream abort", async () => { const entered = Promise.withResolvers(); const listing = Promise.withResolvers<[]>(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index d59e69456e..fa85f90a2b 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -26,6 +26,7 @@ import { getExecutorDiscoverySourceSchema, } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; import { verifyHostedRuntimeSourceBinding } from "#veryfront/agent/hosted/runtime-source-binding.ts"; +import { resolveHostedRuntimeAllowedTools } from "#veryfront/agent/hosted/runtime-request-config.ts"; import { executorAgentFailureCode, executorAgentJson, @@ -302,9 +303,18 @@ export function createExecutorRuntimePreparation(input: Options) { ...normalizeToolNames(definition.deniedTools ?? []), ]), ]; + const sourceToolNames = Array.isArray(definition.tools) + ? resolveHostedRuntimeAllowedTools({ + configuredTools: definition.tools, + configuredDeniedTools: definition.deniedTools, + configuredDelegates: definition.delegates, + configuredSkills: definition.skills, + requestedTools: undefined, + }) + : definition.tools; const allowedToolNames = intersectNames( normalizeToolNames(grant.allowedToolNames), - Array.isArray(definition.tools) ? normalizeToolNames(definition.tools) : definition.tools, + Array.isArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, request.allowedToolNames === undefined ? undefined : normalizeToolNames(request.allowedToolNames), From f8afc77172b4d56587cd25124a5135e07c39e5ab Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 12:29:47 +0200 Subject: [PATCH 041/194] fix(agent): apply authored capability defaults before executor grants --- .../hosted/executor-runtime-prepare.test.ts | 74 ++++++++++++++++++- src/agent/hosted/executor-runtime-prepare.ts | 26 ++++--- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 20f946eab9..bf598d0114 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -548,14 +548,30 @@ describe("executor runtime preparation review regressions", () => { { name: "empty request", requested: [], expected: [] }, { name: "denied delegate", denied: ["agent_writer"], expected: ["read_file"] }, { name: "missing grant", granted: [], expected: [] }, + { name: "omitted tools", frontmatter: "", expected: [] }, + { + name: "omitted tools with delegates", + frontmatter: "delegates: [writer, ungranted]", + expected: ["agent_writer"], + }, + { + name: "unrestricted tools with a denial", + frontmatter: "tools: true", + denied: ["read_file"], + expected: [], + }, + { + name: "unrestricted tools without denials", + frontmatter: "tools: true", + expected: ["read_file", "agent_writer", "agent_undeclared"], + }, ] ) { - it(`preserves Markdown delegate bindings within authority: ${selection.name}`, async () => { + it(`applies Markdown tool bindings within authority: ${selection.name}`, async () => { const definition = parseRuntimeAgentMarkdownDefinition({ id: "coder", content: `--- -tools: [read_file] -delegates: [writer, ungranted] +${selection.frontmatter ?? "tools: [read_file]\ndelegates: [writer, ungranted]"} denied-tools: ${JSON.stringify(selection.denied ?? [])} --- Synthetic source instructions.`, @@ -617,6 +633,58 @@ Synthetic source instructions.`, }); } + for ( + const selection of [ + { name: "omitted binding", expected: [] }, + { name: "explicit binding", configured: ["web_search"], expected: ["web_search"] }, + { name: "empty binding", configured: [], expected: [] }, + { name: "request cannot add a binding", requested: ["web_search"], expected: [] }, + { name: "binding requires a grant", configured: ["web_search"], granted: [], expected: [] }, + { + name: "request removes a binding", + configured: ["web_search"], + requested: [], + expected: [], + }, + ] + ) { + it(`selects provider tools within authored bindings: ${selection.name}`, async () => { + let visible: string[] = []; + const f = fixture({ + config: { providerTools: selection.configured }, + grant: { + ...grant, + models: new Map([[modelId, { + maxOutputTokens: 200, + providerToolNames: selection.granted ?? ["web_search"], + }]]), + }, + facades: { + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync( + await preparedStream(f, { + agentId: "coder", + ...(selection.requested === undefined + ? {} + : { providerToolNames: selection.requested }), + }), + ); + assertEquals(visible, selection.expected); + } finally { + await f.owner.close(); + } + }); + } + it("retains discovery resources during preparation after upstream abort", async () => { const entered = Promise.withResolvers(); const listing = Promise.withResolvers<[]>(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index fa85f90a2b..3bf9dd75d5 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -26,7 +26,10 @@ import { getExecutorDiscoverySourceSchema, } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; import { verifyHostedRuntimeSourceBinding } from "#veryfront/agent/hosted/runtime-source-binding.ts"; -import { resolveHostedRuntimeAllowedTools } from "#veryfront/agent/hosted/runtime-request-config.ts"; +import { + resolveHostedRuntimeAllowedProviderTools, + resolveHostedRuntimeAllowedTools, +} from "#veryfront/agent/hosted/runtime-request-config.ts"; import { executorAgentFailureCode, executorAgentJson, @@ -303,15 +306,13 @@ export function createExecutorRuntimePreparation(input: Options) { ...normalizeToolNames(definition.deniedTools ?? []), ]), ]; - const sourceToolNames = Array.isArray(definition.tools) - ? resolveHostedRuntimeAllowedTools({ - configuredTools: definition.tools, - configuredDeniedTools: definition.deniedTools, - configuredDelegates: definition.delegates, - configuredSkills: definition.skills, - requestedTools: undefined, - }) - : definition.tools; + const sourceToolNames = resolveHostedRuntimeAllowedTools({ + configuredTools: definition.tools, + configuredDeniedTools: definition.deniedTools, + configuredDelegates: definition.delegates, + configuredSkills: definition.skills, + requestedTools: undefined, + }); const allowedToolNames = intersectNames( normalizeToolNames(grant.allowedToolNames), Array.isArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, @@ -322,7 +323,10 @@ export function createExecutorRuntimePreparation(input: Options) { ); const providerToolNames = intersectNames( modelGrant.providerToolNames, - definition.providerTools, + resolveHostedRuntimeAllowedProviderTools({ + configuredProviderTools: definition.providerTools, + requestedTools: undefined, + }), request.providerToolNames, definition.deniedTools, ); From 776f39d2a02c9908a1b0aba67a8ae1d84a9dfcc1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 12:39:22 +0200 Subject: [PATCH 042/194] fix(agent): refuse navigation without executor project-switch authority --- .../hosted/executor-runtime-prepare.test.ts | 102 ++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 8 +- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index bf598d0114..3a8cb6a6c0 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -541,6 +541,108 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + it("refuses selected project navigation before starting fixed-project facades", async () => { + let facadeCalls = 0; + const f = fixture({ + grant: { + ...grant, + allowedToolNames: ["studio_open_project"], + remoteToolSourceIds: ["studio"], + execution: { kind: "ephemeral", projectId: "project-one" }, + }, + facades: { + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => "Synthetic source instructions.", + }, + resolveModelRuntime: () => { + facadeCalls++; + return model; + }, + remoteToolSources: new Map([["studio", { + id: "studio", + listTools: () => { + facadeCalls++; + return Promise.resolve([syntheticRemoteTool("studio_open_project")]); + }, + executeTool: () => { + facadeCalls++; + return Promise.resolve({ ok: true }); + }, + }]]), + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(facadeCalls, 0); + } finally { + await f.owner.close(); + } + }); + + it("keeps unselected navigation outside a run bound to its granted project", async () => { + const executions: { name: string; args: Record }[] = []; + let calls = 0; + const f = fixture({ + grant: { + ...grant, + allowedToolNames: ["studio_open_project", "update_file"], + remoteToolSourceIds: ["studio"], + execution: { kind: "ephemeral", projectId: "project-one" }, + }, + facades: { + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => "Synthetic source instructions.", + }, + remoteToolSources: new Map([["studio", { + id: "studio", + listTools: () => + Promise.resolve(["studio_open_project", "update_file"].map((name) => ({ + ...syntheticRemoteTool(name), + parameters: { + type: "object", + properties: { project_reference: { type: "string" } }, + required: ["project_reference"], + }, + }))), + executeTool: (name, args) => { + executions.push({ name, args }); + return Promise.resolve({ ok: true }); + }, + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + assertEquals((options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name), [ + "update_file", + ]); + return finishStream(calls++ === 0 ? "update_file" : undefined, { + project_reference: "project-two", + }); + }, + }), + }, + }); + try { + await Array.fromAsync( + await preparedStream(f, { + agentId: "coder", + allowedToolNames: ["update_file"], + }), + ); + assertEquals(executions, [{ + name: "update_file", + args: { project_reference: "project-one" }, + }]); + } finally { + await f.owner.close(); + } + }); + for ( const selection of [ { name: "declared delegate", expected: ["read_file", "agent_writer"] }, diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 3bf9dd75d5..fb69995b78 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -148,7 +148,10 @@ function intersectNames( ); } -/** One allocation's static preparation/stream dispatcher. Metadata never installs execution authority. */ +/** + * One allocation's fixed-project preparation/stream dispatcher. Metadata never + * installs execution authority; project navigation requires separate broker support. + */ export function createExecutorRuntimePreparation(input: Options) { const grant = snapshotGrant(input.grant); const binding = parseRuntimePreparationData(getExecutorBindingSchema(), input.binding); @@ -321,6 +324,9 @@ export function createExecutorRuntimePreparation(input: Options) { : normalizeToolNames(request.allowedToolNames), deniedToolNames, ); + if (allowedToolNames.includes("studio_open_project")) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } const providerToolNames = intersectNames( modelGrant.providerToolNames, resolveHostedRuntimeAllowedProviderTools({ From 3869c14563c1fc7931ec1b4f96060697b56867f0 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 12:53:58 +0200 Subject: [PATCH 043/194] fix(agent): cancel remote tool listing during executor preparation --- .../hosted/chat-runtime-tool-assembly.ts | 2 + .../hosted/executor-runtime-prepare.test.ts | 50 +++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 1 + 3 files changed, 53 insertions(+) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index d5ecfaf8eb..63970a1da7 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -165,6 +165,7 @@ export type PrepareFacadedHostedChatRuntimeToolAssemblyInput< & { taskContext: Omit; remoteToolSources: readonly RemoteToolSource[]; + signal?: AbortSignal; loadLatestConversationUserText?: () => Promise; }; @@ -503,6 +504,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const listedRemoteToolNames = await listProjectScopedRemoteToolNames(remoteToolSources, { projectId: activeProjectId(input.taskContext), projectScopedRemoteToolOptions: input.projectScopedRemoteToolOptions, + ...("remoteToolSources" in input ? { context: { abortSignal: input.signal } } : {}), }); const remoteToolNames = applySourceIntegrationPolicy( listedRemoteToolNames, diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 3a8cb6a6c0..8baaa396cf 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -455,6 +455,56 @@ describe("executor runtime preparation", () => { assertEquals(f.discoveryCleanups, 1); }); + for (const cancellation of ["operation", "owner", "discovery"] as const) { + it(`cancels remote facade listing after ${cancellation} cancellation`, async () => { + const entered = Promise.withResolvers(); + const listing = Promise.withResolvers<[]>(); + const operationAbort = new AbortController(); + let listingSignal: AbortSignal | undefined; + const f = fixture({ + grant: { ...grant, remoteToolSourceIds: ["api"] }, + facades: { + remoteToolSources: new Map([["api", { + id: "api", + listTools: (context) => { + listingSignal = context?.abortSignal; + listingSignal?.addEventListener("abort", () => { + listing.reject(new Error("Synthetic listing cancellation")); + }, { once: true }); + entered.resolve(); + return listing.promise; + }, + executeTool: () => Promise.reject(new Error("Unused")), + }]]), + }, + }); + const operation = f.owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + const pending = operation.handle({ agentId: "coder" }, { + binding, + signal: operationAbort.signal, + deadline: Date.now() + 30_000, + }); + try { + await entered.promise; + assert(listingSignal, "Remote listing must receive the preparation signal"); + assertEquals(listingSignal.aborted, false); + if (cancellation === "operation") operationAbort.abort(); + else if (cancellation === "discovery") f.controller.abort(); + else void f.owner.close(); + assertEquals(listingSignal.aborted, true); + assertEquals(await pending, { ok: false, code: "ABORTED" }); + await f.owner.settled; + assertEquals(f.cleanups, 1); + assertEquals(f.discoveryCleanups, 1); + } finally { + listing.resolve([]); + await pending; + await f.owner.close(); + } + }); + } + it("joins late preparation and cleanup after cancellation", async () => { const listing = Promise.withResolvers<[]>(); const entered = Promise.withResolvers(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index fb69995b78..18028183ee 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -432,6 +432,7 @@ export function createExecutorRuntimePreparation(input: Options) { : {}), }; const toolAssembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + signal: context.signal, taskContext, instructions: options.instructions, localTools, From 4488cdc54ba52851e9062e7089b23601c9ed07f4 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 13:22:20 +0200 Subject: [PATCH 044/194] fix(agent): close prepared runtime tool boundaries --- .../hosted/chat-runtime-tool-assembly.test.ts | 1 + .../hosted/chat-runtime-tool-assembly.ts | 6 +- src/agent/hosted/default-chat-runtime.ts | 58 ++++++++++++------- .../hosted/executor-runtime-prepare.test.ts | 56 ++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 8 ++- 5 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index 18424ca6f9..7fd1df7dca 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -43,6 +43,7 @@ it("facaded assembly preserves project tool normalization and mutation callbacks }, }; const assembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + signal: new AbortController().signal, taskContext: { projectId: "project-1", branchId: null, diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 63970a1da7..5dec94eed5 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -165,8 +165,8 @@ export type PrepareFacadedHostedChatRuntimeToolAssemblyInput< & { taskContext: Omit; remoteToolSources: readonly RemoteToolSource[]; - signal?: AbortSignal; - loadLatestConversationUserText?: () => Promise; + signal: AbortSignal; + loadLatestConversationUserText?: (signal: AbortSignal) => Promise; }; type FacadedHostedChatRuntimeToolAssemblyResult = HostedChatRuntimeToolAssemblyResult & { @@ -590,7 +590,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< if (input.loadLatestConversationUserText) { updateDefaultResearchArtifacts({ taskContext: input.taskContext, - latestUserText: await input.loadLatestConversationUserText(), + latestUserText: await input.loadLatestConversationUserText(input.signal), system: systemInstructions, }); } diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 13902bdcee..8ae591203e 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -411,41 +411,55 @@ function snapshotHostedToolResult(result: unknown): unknown { return snapshot.value; } -/** @internal Scope local tool execution and sanitize errors without trusted provenance. */ -export function scopeHostedRuntimeTools(input: { - tools: ToolSet; - taskContext: DefaultHostedChatRuntimeTaskContext; - cloudContext: VeryfrontCloudContext; -}): ToolSet { +/** @internal Bound tool results and sanitize errors without trusted provenance. */ +export function scopeHostedRuntimeToolResults(tools: ToolSet): ToolSet { return Object.fromEntries( - Object.entries(input.tools).map(([toolName, tool]) => { + Object.entries(tools).map(([toolName, tool]) => { const execute = tool.execute; const preserveTrustedError = hasTrustedHostToolProvenance(tool); return [ toolName, { ...tool, - execute: (toolInput: unknown, context?: ToolExecutionContext) => - withoutHostedCredentials({ - taskContext: input.taskContext, - cloudContext: input.cloudContext, - operation: async () => { - try { - return snapshotHostedToolResult( - await apply(execute, tool, [toolInput, context]), - ); - } catch (error) { - if (preserveTrustedError) throw error; - throw new TypeErrorConstructor("Hosted project tool execution failed"); - } - }, - }), + execute: async (toolInput: unknown, context?: ToolExecutionContext) => { + try { + return snapshotHostedToolResult( + await apply(execute, tool, [toolInput, context]), + ); + } catch (error) { + if (preserveTrustedError) throw error; + throw new TypeErrorConstructor("Hosted project tool execution failed"); + } + }, }, ]; }), ); } +/** @internal Scope local tool execution and sanitize errors without trusted provenance. */ +export function scopeHostedRuntimeTools(input: { + tools: ToolSet; + taskContext: DefaultHostedChatRuntimeTaskContext; + cloudContext: VeryfrontCloudContext; +}): ToolSet { + const scopedTools = scopeHostedRuntimeToolResults(input.tools); + return Object.fromEntries( + Object.entries(scopedTools).map(([toolName, tool]) => [ + toolName, + { + ...tool, + execute: (toolInput: unknown, context?: ToolExecutionContext) => + withoutHostedCredentials({ + taskContext: input.taskContext, + cloudContext: input.cloudContext, + operation: () => apply(tool.execute, tool, [toolInput, context]), + }), + }, + ]), + ); +} + function runWithDefaultHostedRequestContext( input: { taskContext: DefaultHostedChatRuntimeTaskContext; diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 8baaa396cf..043a46a202 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -591,6 +591,62 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + it("sanitizes project tool failures before streaming them from the prepared runtime", async () => { + const privateDetail = "synthetic-private-project-detail"; + let modelCalls = 0; + const projectRuntime = runtime({ tools: { project_failure: true } }); + projectRuntime.tools.set("project_failure", { + id: "project_failure", + type: "function", + description: "Synthetic project tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + throw new Error(privateDetail); + }, + }); + const f = fixture({ + grant: { ...grant, allowedToolNames: ["project_failure"] }, + load: () => Promise.resolve(projectRuntime), + facades: { + resolveModelRuntime: () => ({ + ...model, + doStream: () => finishStream(modelCalls++ === 0 ? "project_failure" : undefined), + }), + }, + }); + try { + const frames = await Array.fromAsync(await preparedStream(f)); + const serialized = JSON.stringify(frames); + assertEquals(serialized.includes(privateDetail), false); + assertEquals(serialized.includes("Hosted project tool execution failed"), true); + } finally { + await f.owner.close(); + } + }); + + it("cancels latest conversation text loading before facade cleanup", async () => { + const entered = Promise.withResolvers(); + let observedSignal: AbortSignal | undefined; + const f = fixture({ + facades: { + latestConversationUserText: (signal) => { + observedSignal = signal; + entered.resolve(); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }, + }, + }); + const pending = prepare(f.owner); + await entered.promise; + const closing = f.owner.close(); + assertEquals(observedSignal?.aborted, true); + assertEquals(await pending, { ok: false, code: "ABORTED" }); + await closing; + assertEquals(f.cleanups, 1); + }); + it("refuses selected project navigation before starting fixed-project facades", async () => { let facadeCalls = 0; const f = fixture({ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 18028183ee..5fa2dbbb9c 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -40,6 +40,7 @@ import { createPreparedHostedRuntimeAgent, incrementSteeringRevision, type PreparedHostedRuntimeAgentOptions, + scopeHostedRuntimeToolResults, } from "#veryfront/agent/hosted/default-chat-runtime.ts"; import { prepareFacadedHostedChatRuntimeToolAssembly, @@ -90,7 +91,7 @@ export interface ExecutorRuntimeFacades { ): Promise>; refresh(): Promise | AgentSystem; }; - latestConversationUserText?: () => Promise; + latestConversationUserText?: (signal: AbortSignal) => Promise; publishParentRunEvents?: NonNullable; toolExposureCheckpoint?: { initial?: CreationOptions["serverResolvedToolExposureCheckpoint"]; @@ -477,7 +478,10 @@ export function createExecutorRuntimePreparation(input: Options) { createPreparedHostedRuntimeAgent({ options, taskContext, - toolAssembly, + toolAssembly: { + ...toolAssembly, + runtimeTools: scopeHostedRuntimeToolResults(toolAssembly.runtimeTools), + }, modelId, sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, refreshSystem: facades.projectSteering?.refresh.bind(facades.projectSteering), From 33314c1f5344dcfa2f5b2ced4edebe75db51c57a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 13:51:47 +0200 Subject: [PATCH 045/194] fix(agent): preserve research artifact system reminders --- .../hosted/chat-runtime-tool-assembly.test.ts | 29 +++++++++++++++++++ .../hosted/chat-runtime-tool-assembly.ts | 20 ++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index 7fd1df7dca..889bff1da3 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -74,6 +74,35 @@ const unrestrictedSourceIntegrationPolicy = { mode: "unrestricted", } as const; +for (const structured of [false, true]) { + it(`facaded assembly retains research artifact reminders in system output (${structured})`, async () => { + const content = "Synthetic source instructions"; + const assembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + signal: new AbortController().signal, + taskContext: { projectId: null, model: "veryfront-cloud/openai/gpt-5.4" }, + instructions: structured ? [{ role: "system", content }] : content, + localTools: {}, + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + allowedToolNames: [], + remoteToolSources: [], + loadLatestConversationUserText: () => + Promise.resolve("/research Research synthetic widgets and save the report to the project."), + }); + assertStringIncludes(assembly.systemInstructions, content); + assertStringIncludes(assembly.systemInstructions, "research/synthetic-widgets/report.md"); + if (structured) { + assertExists(assembly.systemMessages); + assertEquals(assembly.systemMessages[0], { role: "system", content }); + assertStringIncludes( + JSON.stringify(assembly.systemMessages), + "research/synthetic-widgets/report.md", + ); + } else { + assertEquals(assembly.systemMessages, undefined); + } + }); +} + function localTool(description: string) { return { description, diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 5dec94eed5..c58b5b1902 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -581,17 +581,16 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< modelInstructions, modelVisibleToolNames, ); - const systemInstructions = flattenSystemInstructions(instructionsWithToolInventory); - const systemMessages = typeof modelInstructions === "string" - ? undefined + let preparedInstructions = typeof modelInstructions === "string" + ? flattenSystemInstructions(instructionsWithToolInventory) : instructionsWithToolInventory; if ("remoteToolSources" in input) { if (input.loadLatestConversationUserText) { - updateDefaultResearchArtifacts({ + preparedInstructions = updateDefaultResearchArtifacts({ taskContext: input.taskContext, latestUserText: await input.loadLatestConversationUserText(input.signal), - system: systemInstructions, + system: preparedInstructions, }); } } else if (input.preloadLatestConversationUserText !== false) { @@ -600,13 +599,20 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< authToken: input.taskContext.authToken, conversationId: input.conversationId, }); - updateDefaultResearchArtifacts({ + preparedInstructions = updateDefaultResearchArtifacts({ taskContext: input.taskContext, latestUserText, - system: systemInstructions, + system: preparedInstructions, }); } + const systemInstructions = typeof preparedInstructions === "string" + ? preparedInstructions + : flattenSystemInstructions(preparedInstructions); + const systemMessages = typeof preparedInstructions === "string" + ? undefined + : preparedInstructions; + return { normalizedAllowedToolNames, authorizedToolNames, From 0967c6e0881a4a0cab5840bf0837a870d71ff8d9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 13:54:45 +0200 Subject: [PATCH 046/194] fix(agent): retain private facade access and cancel steering refresh --- .../hosted/executor-runtime-prepare.test.ts | 86 +++++++++++++++++-- src/agent/hosted/executor-runtime-prepare.ts | 33 +++++-- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 043a46a202..369822d468 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -5,14 +5,14 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; -import type { AgentConfig } from "../types.ts"; +import type { AgentConfig } from "#veryfront/agent/types.ts"; import { parseRuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; import { createRuntimeAgentFromMarkdownDefinition } from "#veryfront/agent/runtime/agent-markdown-adapter.ts"; import type { HostToolDefinition, ToolDefinition } from "#veryfront/tool"; -import { registerModelRuntimeResolverRevoker } from "../runtime/model-transport.ts"; +import { registerModelRuntimeResolverRevoker } from "#veryfront/agent/runtime/model-transport.ts"; import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; -import { agent } from "../factory.ts"; -import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; +import { agent } from "#veryfront/agent/factory.ts"; +import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; import { createExecutorDiscovery } from "./executor-discovery.ts"; import { createExecutorRuntimePreparation, @@ -20,7 +20,7 @@ import { type ExecutorRuntimePreparationGrant, } from "./executor-runtime-prepare.ts"; import { ExecutorRuntimePreparationError } from "./executor-runtime-prepare-schema.ts"; -import { createExecutorChannel } from "../executor/channel.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; import { createExecutorHostedChatRuntimeAgent } from "./executor-agent-bridge.ts"; const binding = { @@ -538,6 +538,7 @@ async function preparedStream( f: ReturnType, request: JsonValue = { agentId: "coder" }, userText = "Synthetic question", + signal = new AbortController().signal, ) { const result = await prepare(f.owner, request); assert(result && typeof result === "object" && !Array.isArray(result) && result.ok === true); @@ -555,7 +556,7 @@ async function preparedStream( }], }, { binding, - signal: new AbortController().signal, + signal, deadline: Date.now() + 30_000, }); } @@ -591,6 +592,79 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + for (const cancellation of ["operation", "owner"] as const) { + it(`cancels steering refresh on ${cancellation} abort and joins it before cleanup`, async () => { + const entered = Promise.withResolvers(); + const unblock = Promise.withResolvers(); + const abort = new AbortController(); + let refreshSignal: AbortSignal | undefined; + let calls = 0; + const f = fixture({ + grant: { + ...grant, + allowedToolNames: ["update_file"], + remoteToolSourceIds: ["api"], + execution: { kind: "ephemeral", projectId: "synthetic-project" }, + }, + facades: { + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: async (signal?: AbortSignal) => { + refreshSignal = signal; + entered.resolve(); + await unblock.promise; + signal?.throwIfAborted(); + return "Updated instructions"; + }, + }, + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => + Promise.resolve([{ + ...syntheticRemoteTool("update_file"), + parameters: { + type: "object", + properties: { path: { type: "string" }, project_reference: { type: "string" } }, + required: ["project_reference"], + }, + }]), + executeTool: () => Promise.resolve({ success: true }), + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: () => + finishStream(calls++ === 0 ? "update_file" : undefined, { path: "AGENTS.md" }), + }), + }, + }); + const consuming = Array.fromAsync( + await preparedStream(f, { agentId: "coder" }, "Synthetic question", abort.signal), + ).catch(() => []); + try { + await Promise.race([ + entered.promise, + consuming.then((frames) => { + throw new Error(`Stream completed before refresh: ${JSON.stringify(frames)}`); + }), + ]); + assert(refreshSignal, "refresh must receive its runtime cancellation signal"); + assertEquals(refreshSignal.aborted, false); + if (cancellation === "operation") abort.abort(); + else void f.owner.close(); + assertEquals(refreshSignal.aborted, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(f.cleanups, 0); + assertEquals(f.discoveryCleanups, 0); + } finally { + unblock.resolve(); + await consuming; + await f.owner.close(); + } + assertEquals(f.cleanups, 1); + assertEquals(f.discoveryCleanups, 1); + }); + } + it("sanitizes project tool failures before streaming them from the prepared runtime", async () => { const privateDetail = "synthetic-private-project-detail"; let modelCalls = 0; diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 5fa2dbbb9c..8e5d9f5105 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -67,6 +67,17 @@ import { parseRuntimePreparationData, } from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; +const apply = Reflect.apply; +const mapGet = Map.prototype.get; +const mapHas = Map.prototype.has; + +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; +} + type CreationOptions = HostedChatRuntimeCreationOptions< RuntimeAgentMarkdownDefinition, RuntimeAgentThinkingConfig @@ -89,7 +100,7 @@ export interface ExecutorRuntimeFacades { signal: AbortSignal; }, ): Promise>; - refresh(): Promise | AgentSystem; + refresh(signal: AbortSignal): Promise | AgentSystem; }; latestConversationUserText?: (signal: AbortSignal) => Promise; publishParentRunEvents?: NonNullable; @@ -170,6 +181,7 @@ export function createExecutorRuntimePreparation(input: Options) { let cleanup: Promise | undefined; let startup: Promise> | undefined; let producerCompletion: Promise | undefined; + let streamSignal = lifetime.signal; const settled = Promise.withResolvers(); void settled.promise.catch(() => {}); const assertActive = () => { @@ -224,10 +236,12 @@ export function createExecutorRuntimePreparation(input: Options) { typeof facades.resolveModelRuntime !== "function" || typeof facades.cleanup !== "function" ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); for (const id of effective.hostToolFacadeIds) { - if (!facades.hostTools.has(id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + if (!privateMapHas(facades.hostTools, id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); } for (const id of effective.remoteToolSourceIds) { - if (!facades.remoteToolSources.has(id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + if (!privateMapHas(facades.remoteToolSources, id)) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } } for (const server of definition.mcpServers ?? []) { if (!effective.remoteToolSourceIds.includes(server.id ?? server.kind)) { @@ -289,13 +303,15 @@ export function createExecutorRuntimePreparation(input: Options) { // Enroll only after agent.describe returns: a failed discovery operation // can await discovery.close(). No remaining preparation work awaits it. input.discovery.retainRuntimeTask(preparation!); - const localTools: HostToolSet = Object.fromEntries( + let localTools: HostToolSet = Object.fromEntries( [...runtime.tools].filter(([id, value]) => !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId: definition.id }) ), ); for (const id of grant.hostToolFacadeIds) { - Object.assign(localTools, facades.hostTools.get(id)); + // 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({ @@ -456,7 +472,7 @@ export function createExecutorRuntimePreparation(input: Options) { (definition.mcpServers ?? []).filter((server) => (server.id ?? server.kind) === id) .reduce( (source, server) => wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy), - facades.remoteToolSources.get(id)!, + privateMapGet(facades.remoteToolSources, id)!, ) ), onSteeringMutation: (mutation) => { @@ -484,7 +500,9 @@ export function createExecutorRuntimePreparation(input: Options) { }, modelId, sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - refreshSystem: facades.projectSteering?.refresh.bind(facades.projectSteering), + refreshSystem: facades.projectSteering + ? () => facades.projectSteering!.refresh(streamSignal) + : undefined, }, { resolveModelRuntime, preserveToolCatalog: true, @@ -499,6 +517,7 @@ export function createExecutorRuntimePreparation(input: Options) { preparedOperations = createExecutorAgentOperations({ preparedRuntimeHandle, startStream: (streamInput) => { + streamSignal = streamInput.abortSignal; startup = Promise.resolve().then(() => { assertActive(); return createHostedChatRuntimeDataStream({ From 61abc1e9a6bbf5d8dcf399ab37728f38abd4b1e4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:06:52 +0200 Subject: [PATCH 047/194] fix(agent): preserve granted skill infrastructure during preparation --- .../hosted/executor-runtime-prepare.test.ts | 114 ++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 22 +++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 369822d468..b0691253c6 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -592,6 +592,120 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + for ( + const selection of [ + { + name: "omitted tools", + tools: "", + expected: ["invoke_agent", "load_skill"], + }, + { + name: "configured tools", + tools: "tools: [read_file]", + expected: ["invoke_agent", "load_skill", "read_file"], + }, + { name: "empty caller selector", tools: "", requested: [], expected: [] }, + { + name: "caller narrows configured tools", + tools: "tools: [read_file]", + requested: ["read_file"], + expected: ["read_file"], + }, + { name: "missing grants", tools: "", granted: [], expected: [] }, + { + name: "denied infrastructure", + tools: "denied-tools: [load_skill, load_skill_reference, invoke_agent]", + expected: [], + }, + { + name: "unrestricted denial", + tools: "tools: true\ndenied-tools: [read_file]", + expected: [], + }, + { name: "no matching skills", tools: "", skills: [], expected: [] }, + ] + ) { + it(`retains granted skill infrastructure for Markdown agents: ${selection.name}`, async () => { + const state = runtime(); + state.agents.set( + "coder", + createRuntimeAgentFromMarkdownDefinition(parseRuntimeAgentMarkdownDefinition({ + id: "coder", + content: `---\nskills: true\n${selection.tools}\n---\nSynthetic instructions.`, + })), + ); + const names = [ + "load_skill", + "load_skill_reference", + "invoke_agent", + "execute_skill_script", + "read_file", + ]; + let visible: string[] = []; + const executions: string[] = []; + let calls = 0; + const f = fixture({ + load: () => Promise.resolve(state), + grant: { + ...grant, + allowedToolNames: selection.granted ?? names, + hostToolFacadeIds: ["skills"], + }, + facades: { + hostTools: new Map([[ + "skills", + Object.fromEntries(names.map((name) => [name, { + ...syntheticHostTool(), + execute: () => { + executions.push(name); + return { ok: true }; + }, + }])), + ]]), + projectSteering: { + prepare: ({ definition }) => + Promise.resolve({ + agent: definition, + initialSkills: selection.skills ?? + [{ + id: "example", + name: "Example", + description: "Synthetic", + instructions: "Synthetic instructions", + allowedTools: [], + }], + }), + refresh: () => "Synthetic instructions", + }, + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream( + calls++ === 0 && visible.includes("invoke_agent") ? "invoke_agent" : undefined, + ); + }, + }), + }, + }); + try { + await Array.fromAsync( + await preparedStream(f, { + agentId: "coder", + ...(selection.requested === undefined ? {} : { allowedToolNames: selection.requested }), + }), + ); + assertEquals([...visible].sort(), [...selection.expected].sort()); + assertEquals( + executions, + selection.expected.some((name) => name === "invoke_agent") ? ["invoke_agent"] : [], + ); + } finally { + await f.owner.close(); + } + }); + } + for (const cancellation of ["operation", "owner"] as const) { it(`cancels steering refresh on ${cancellation} abort and joins it before cleanup`, async () => { const entered = Promise.withResolvers(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 8e5d9f5105..dfb5586576 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -30,6 +30,7 @@ import { resolveHostedRuntimeAllowedProviderTools, resolveHostedRuntimeAllowedTools, } from "#veryfront/agent/hosted/runtime-request-config.ts"; +import { resolveHostedRuntimeAllowedToolNames } from "#veryfront/agent/hosted/runtime-essential-tools.ts"; import { executorAgentFailureCode, executorAgentJson, @@ -333,7 +334,7 @@ export function createExecutorRuntimePreparation(input: Options) { configuredSkills: definition.skills, requestedTools: undefined, }); - const allowedToolNames = intersectNames( + let allowedToolNames = intersectNames( normalizeToolNames(grant.allowedToolNames), Array.isArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, request.allowedToolNames === undefined @@ -381,6 +382,25 @@ export function createExecutorRuntimePreparation(input: Options) { agentId: definition.id, selector: definition.skills === false ? [] : definition.skills, }); + if (sourceToolNames !== undefined) { + const effectiveSourceTools = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: normalizeToolNames(sourceToolNames), + localToolNames: normalizeToolNames(grant.allowedToolNames).filter((name) => + Object.hasOwn(localTools, name) + ), + availableSkillIds: skills.allowedSkillIds, + configDerivedSelector: request.allowedToolNames === undefined && + !(definition.tools === true && Boolean(definition.deniedTools?.length)), + }); + allowedToolNames = intersectNames( + normalizeToolNames(grant.allowedToolNames), + effectiveSourceTools === null ? undefined : [...effectiveSourceTools], + request.allowedToolNames === undefined + ? undefined + : normalizeToolNames(request.allowedToolNames), + deniedToolNames, + ); + } const taskContext = { ...execution, steeringRevision: 0, From 31a77d56fc131796784a2f9e4e048b45d13b5c79 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:08:23 +0200 Subject: [PATCH 048/194] fix(agent): capture facade membership intrinsic before discovery --- src/agent/hosted/executor-runtime-prepare.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index dfb5586576..65e0d86159 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -71,6 +71,7 @@ import { const apply = Reflect.apply; const mapGet = Map.prototype.get; const mapHas = Map.prototype.has; +const hasOwn = Object.hasOwn; function privateMapGet(map: ReadonlyMap, key: K): V | undefined { return apply(mapGet, map, [key]) as V | undefined; @@ -386,7 +387,7 @@ export function createExecutorRuntimePreparation(input: Options) { const effectiveSourceTools = resolveHostedRuntimeAllowedToolNames({ allowedToolNames: normalizeToolNames(sourceToolNames), localToolNames: normalizeToolNames(grant.allowedToolNames).filter((name) => - Object.hasOwn(localTools, name) + hasOwn(localTools, name) ), availableSkillIds: skills.allowedSkillIds, configDerivedSelector: request.allowedToolNames === undefined && From 703f67c218e8932fdc61b004628029969b55f0b9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:18:02 +0200 Subject: [PATCH 049/194] fix(agent): capture tool enumeration and grant filtering intrinsics --- .../hosted/chat-runtime-tool-assembly.ts | 62 +++++++++++-------- src/agent/hosted/executor-runtime-prepare.ts | 42 ++++++++----- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index c58b5b1902..6ff17c29da 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -47,6 +47,18 @@ import type { RuntimeToolLoadingMode } from "../runtime/runtime-tool-config.ts"; import { TOOL_SEARCH_TOOL_NAME } from "../runtime/tool-exposure.ts"; import { compareStrings } from "#veryfront/utils/compare.ts"; +// Capture collection operations before project discovery can modify shared built-ins. +const objectEntries = Object.entries; +const objectFromEntries = Object.fromEntries; +const objectKeys = Object.keys; +const objectHasOwn = Object.hasOwn; +const apply = Reflect.apply; +const arrayFilter = Array.prototype.filter; + +function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { + return apply(arrayFilter, values, [predicate]) as T[]; +} + /** Context for hosted chat runtime tool assembly. */ export type HostedChatRuntimeToolAssemblyContext = DefaultResearchArtifactContext & { authToken: string; @@ -217,11 +229,10 @@ function withoutDeniedHostTools( return tools; } const denied = new Set(deniedToolNames); - return Object.fromEntries( - Object.entries(tools).filter(([toolName, tool]) => + return objectFromEntries( + filter(objectEntries(tools), ([toolName, tool]) => !denied.has(toolName) && - (tool.shortName === undefined || !denied.has(tool.shortName)) - ), + (tool.shortName === undefined || !denied.has(tool.shortName))), ); } @@ -258,11 +269,10 @@ function applyHostedHostToolPolicy( return tools; } const allowed = new Set(policy.allow); - return Object.fromEntries( - Object.entries(tools).filter(([registeredName, tool]) => + return objectFromEntries( + filter(objectEntries(tools), ([registeredName, tool]) => allowed.has(registeredName) || - (tool.shortName !== undefined && allowed.has(tool.shortName)) - ), + (tool.shortName !== undefined && allowed.has(tool.shortName))), ); } @@ -293,8 +303,8 @@ function filterPostFormInputLocalTools( } const blockedToolNames = new Set(["form_input", "load_skill"]); - return Object.fromEntries( - Object.entries(tools).filter(([toolName]) => !blockedToolNames.has(toolName)), + return objectFromEntries( + filter(objectEntries(tools), ([toolName]) => !blockedToolNames.has(toolName)), ); } @@ -307,7 +317,7 @@ function resolveOwnerScopedToolName(input: { return input.toolName; } - for (const [registeredName, tool] of Object.entries(input.localTools)) { + for (const [registeredName, tool] of objectEntries(input.localTools)) { if ( tool.ownerAgentId === input.agentId && tool.shortName === input.toolName @@ -351,11 +361,12 @@ export function filterHostedChatRuntimeLocalTools(input: { sourceProviderToolNames?: readonly string[]; }): HostToolSet { const allowedToolNames = normalizeHostedRuntimeAllowedToolNames(input.allowedToolNames); - const entries = Object.entries(input.tools).filter(([toolName]) => - allowedToolNames ? allowedToolNames.has(toolName) : true + const entries = filter( + objectEntries(input.tools), + ([toolName]) => allowedToolNames ? allowedToolNames.has(toolName) : true, ); - return Object.fromEntries(entries.sort(([left], [right]) => compareStrings(left, right))); + return objectFromEntries(entries.sort(([left], [right]) => compareStrings(left, right))); } function shouldIncludeHostedWebFetchFallback(input: { @@ -365,7 +376,7 @@ function shouldIncludeHostedWebFetchFallback(input: { allowedProviderToolNames: ReadonlySet | null; providerNativeToolNames: readonly string[]; }): boolean { - if (!Object.hasOwn(input.localTools, "web_fetch")) { + if (!objectHasOwn(input.localTools, "web_fetch")) { return false; } if (input.providerNativeToolNames.includes("web_fetch")) { @@ -402,7 +413,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ); const allowedToolNames = resolveHostedRuntimeAllowedToolNames({ allowedToolNames: normalizedAllowedToolNames, - localToolNames: Object.keys(authorizedLocalTools), + localToolNames: objectKeys(authorizedLocalTools), availableSkillIds: input.taskContext.availableSkillIds, configDerivedSelector: configDerivedSelector || (input.includeRuntimeEssentialToolsWhenEmpty === true && @@ -422,11 +433,12 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< input.allowedProviderToolNames, ); const providerNativeToolNames = getProviderNativeToolNames({ model: input.taskContext.model }); - const sortedLocalToolEntries = Object.entries(selectedLocalTools).filter(([toolName]) => - isIntegrationToolAllowedBySourcePolicy(toolName, input.sourceIntegrationPolicy) + const sortedLocalToolEntries = filter( + objectEntries(selectedLocalTools), + ([toolName]) => isIntegrationToolAllowedBySourcePolicy(toolName, input.sourceIntegrationPolicy), ); if ( - !Object.hasOwn(selectedLocalTools, "web_fetch") && + !objectHasOwn(selectedLocalTools, "web_fetch") && shouldIncludeHostedWebFetchFallback({ localTools: postFormInputLocalTools, sourceProviderToolNames, @@ -440,7 +452,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< sortedLocalToolEntries.push(["web_fetch", hostedWebFetchTool]); } } - const sortedLocalTools = Object.fromEntries( + const sortedLocalTools = objectFromEntries( sortedLocalToolEntries.sort(([left], [right]) => compareStrings(left, right)), ); const localHostTools = input.traceLocalTools @@ -511,7 +523,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< input.sourceIntegrationPolicy, ); const localProviderToolNames = new Set( - Object.keys(sortedLocalTools).filter((toolName) => providerNativeToolNames.includes(toolName)), + objectKeys(sortedLocalTools).filter((toolName) => providerNativeToolNames.includes(toolName)), ); // Explicit denials also bind provider-native tools: a denied name must not // reach the model through the provider channel after the host and remote @@ -534,7 +546,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< // Materialize before validation and provider capping so skipped descriptors // cannot advertise capabilities that the runtime cannot execute. const localRuntimeTools = createToolsFromHostDefinitions(localHostTools); - const localToolNames = Object.keys(localRuntimeTools); + const localToolNames = objectKeys(localRuntimeTools); const toolSearchDenied = deniedProviderToolNames.has(TOOL_SEARCH_TOOL_NAME); const toolLoadingMode: RuntimeToolLoadingMode = normalizedAllowedToolNames === null && !toolSearchDenied @@ -555,10 +567,10 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const compatibleToolNames = new Set(availableToolNames); const compatibleLocalRuntimeTools = toolLoadingMode === "deferred" ? localRuntimeTools - : Object.fromEntries( - Object.entries(localRuntimeTools).filter(([toolName]) => compatibleToolNames.has(toolName)), + : objectFromEntries( + filter(objectEntries(localRuntimeTools), ([toolName]) => compatibleToolNames.has(toolName)), ); - const compatibleLocalToolNames = Object.keys(compatibleLocalRuntimeTools); + const compatibleLocalToolNames = objectKeys(compatibleLocalRuntimeTools); const compatibleRemoteToolNames = toolLoadingMode === "deferred" ? remoteToolNames : remoteToolNames.filter((toolName) => compatibleToolNames.has(toolName)); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 65e0d86159..2699c63243 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -72,6 +72,15 @@ const apply = Reflect.apply; const mapGet = Map.prototype.get; const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; +const arrayFilter = Array.prototype.filter; +const arrayIncludes = Array.prototype.includes; + +function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { + return apply(arrayFilter, values, [predicate]) as T[]; +} +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; @@ -156,9 +165,11 @@ function intersectNames( requested: readonly string[] | undefined, denied: readonly string[] = [], ) { - return granted.filter((name) => - (source === undefined || source === true || source.includes(name)) && - (requested === undefined || requested.includes(name)) && !denied.includes(name) + return filter( + granted, + (name) => + (source === undefined || source === true || includes(source, name)) && + (requested === undefined || includes(requested, name)) && !includes(denied, name), ); } @@ -246,18 +257,18 @@ export function createExecutorRuntimePreparation(input: Options) { } } for (const server of definition.mcpServers ?? []) { - if (!effective.remoteToolSourceIds.includes(server.id ?? server.kind)) { + if (!includes(effective.remoteToolSourceIds, server.id ?? server.kind)) { refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); } } if ( (effective.execution.projectId !== null || - effective.requiredCapabilities?.includes("project-steering")) && + includes(effective.requiredCapabilities ?? [], "project-steering")) && (typeof facades.projectSteering?.prepare !== "function" || typeof facades.projectSteering?.refresh !== "function") ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); if ( - effective.requiredCapabilities?.includes("conversation-user-text") && + includes(effective.requiredCapabilities ?? [], "conversation-user-text") && typeof facades.latestConversationUserText !== "function" ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); if (effective.execution.kind === "canonical") { @@ -306,8 +317,10 @@ export function createExecutorRuntimePreparation(input: Options) { // can await discovery.close(). No remaining preparation work awaits it. input.discovery.retainRuntimeTask(preparation!); let localTools: HostToolSet = Object.fromEntries( - [...runtime.tools].filter(([id, value]) => - !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId: definition.id }) + filter( + [...runtime.tools], + ([id, value]) => + !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId: definition.id }), ), ); for (const id of grant.hostToolFacadeIds) { @@ -343,7 +356,7 @@ export function createExecutorRuntimePreparation(input: Options) { : normalizeToolNames(request.allowedToolNames), deniedToolNames, ); - if (allowedToolNames.includes("studio_open_project")) { + if (includes(allowedToolNames, "studio_open_project")) { refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); } const providerToolNames = intersectNames( @@ -386,8 +399,9 @@ export function createExecutorRuntimePreparation(input: Options) { if (sourceToolNames !== undefined) { const effectiveSourceTools = resolveHostedRuntimeAllowedToolNames({ allowedToolNames: normalizeToolNames(sourceToolNames), - localToolNames: normalizeToolNames(grant.allowedToolNames).filter((name) => - hasOwn(localTools, name) + localToolNames: filter( + normalizeToolNames(grant.allowedToolNames), + (name) => hasOwn(localTools, name), ), availableSkillIds: skills.allowedSkillIds, configDerivedSelector: request.allowedToolNames === undefined && @@ -423,7 +437,7 @@ export function createExecutorRuntimePreparation(input: Options) { projectId: execution.projectId, branchId: execution.branchId, instructions: steering.initialProjectInstructions ?? "", - skills: allowedToolNames.includes("load_skill") ? skills.definitions : [], + skills: includes(allowedToolNames, "load_skill") ? skills.definitions : [], environmentContext: steering.environmentContext, availableToolNames: allowedToolNames, }) @@ -490,7 +504,7 @@ export function createExecutorRuntimePreparation(input: Options) { error, }), remoteToolSources: grant.remoteToolSourceIds.map((id) => - (definition.mcpServers ?? []).filter((server) => (server.id ?? server.kind) === id) + filter(definition.mcpServers ?? [], (server) => (server.id ?? server.kind) === id) .reduce( (source, server) => wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy), privateMapGet(facades.remoteToolSources, id)!, @@ -505,7 +519,7 @@ export function createExecutorRuntimePreparation(input: Options) { }); assertActive(); for (const name of toolAssembly.normalizedAllowedToolNames ?? []) { - if (!toolAssembly.authorizedToolNames.includes(name)) { + if (!includes(toolAssembly.authorizedToolNames, name)) { refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); } } From 5a65f6a3b2e045375ce9c97973bc2c5dec3a6210 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 14:13:46 +0200 Subject: [PATCH 050/194] fix(agent): protect private facade reflection --- .../hosted/chat-runtime-tool-assembly.ts | 150 +++++++++++++----- src/agent/hosted/default-chat-runtime.ts | 91 ++++++----- .../hosted/executor-runtime-prepare.test.ts | 34 ++++ src/tool/host-tools.ts | 13 +- 4 files changed, 206 insertions(+), 82 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 6ff17c29da..ae42ce0c39 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -47,18 +47,64 @@ import type { RuntimeToolLoadingMode } from "../runtime/runtime-tool-config.ts"; import { TOOL_SEARCH_TOOL_NAME } from "../runtime/tool-exposure.ts"; import { compareStrings } from "#veryfront/utils/compare.ts"; -// Capture collection operations before project discovery can modify shared built-ins. -const objectEntries = Object.entries; -const objectFromEntries = Object.fromEntries; -const objectKeys = Object.keys; -const objectHasOwn = Object.hasOwn; const apply = Reflect.apply; const arrayFilter = Array.prototype.filter; +const arrayIncludes = Array.prototype.includes; +const arrayMap = Array.prototype.map; +const arraySort = Array.prototype.sort; +const objectDefineProperty = Object.defineProperty; +const objectEntries = Object.entries; +const objectHasOwn = Object.hasOwn; +const objectKeys = Object.keys; + +function ownEntries(value: Record): Array<[string, T]> { + return apply(objectEntries, Object, [value]) as Array<[string, T]>; +} + +function ownKeys(value: object): string[] { + return apply(objectKeys, Object, [value]) as string[]; +} + +function hasOwn(value: object, key: PropertyKey): boolean { + return apply(objectHasOwn, Object, [value, key]) as boolean; +} -function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { +function filterValues( + values: readonly T[], + predicate: (value: T, index: number, array: readonly T[]) => unknown, +): T[] { return apply(arrayFilter, values, [predicate]) as T[]; } +function mapValues( + values: readonly T[], + callback: (value: T, index: number, array: readonly T[]) => U, +): U[] { + return apply(arrayMap, values, [callback]) as U[]; +} + +function sortValues(values: T[], compare: (left: T, right: T) => number): T[] { + return apply(arraySort, values, [compare]) as T[]; +} + +function includesValue(values: readonly T[], value: T): boolean { + return apply(arrayIncludes, values, [value]) as boolean; +} + +function recordFromEntries(entries: readonly (readonly [string, T])[]): Record { + const result: Record = {}; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (entry === undefined) continue; + apply(objectDefineProperty, Object, [ + result, + entry[0], + { value: entry[1], enumerable: true, configurable: true, writable: true }, + ]); + } + return result; +} + /** Context for hosted chat runtime tool assembly. */ export type HostedChatRuntimeToolAssemblyContext = DefaultResearchArtifactContext & { authToken: string; @@ -205,7 +251,7 @@ export function augmentVeryfrontApiMcpServerPolicy( return mcpServers; } - return mcpServers.map((server) => { + return mapValues(mcpServers, (server) => { if (server.kind !== "veryfront-api" || !server.toolPolicy?.allow) { return server; } @@ -229,8 +275,8 @@ function withoutDeniedHostTools( return tools; } const denied = new Set(deniedToolNames); - return objectFromEntries( - filter(objectEntries(tools), ([toolName, tool]) => + return recordFromEntries( + filterValues(ownEntries(tools), ([toolName, tool]) => !denied.has(toolName) && (tool.shortName === undefined || !denied.has(tool.shortName))), ); @@ -258,7 +304,7 @@ function withoutDeniedRemoteTools( sources: RemoteToolSource[], deniedToolNames: readonly string[] | undefined, ): RemoteToolSource[] { - return sources.map((source) => withoutDeniedRemoteTool(source, deniedToolNames)); + return mapValues(sources, (source) => withoutDeniedRemoteTool(source, deniedToolNames)); } function applyHostedHostToolPolicy( @@ -269,8 +315,8 @@ function applyHostedHostToolPolicy( return tools; } const allowed = new Set(policy.allow); - return objectFromEntries( - filter(objectEntries(tools), ([registeredName, tool]) => + return recordFromEntries( + filterValues(ownEntries(tools), ([registeredName, tool]) => allowed.has(registeredName) || (tool.shortName !== undefined && allowed.has(tool.shortName))), ); @@ -303,8 +349,8 @@ function filterPostFormInputLocalTools( } const blockedToolNames = new Set(["form_input", "load_skill"]); - return objectFromEntries( - filter(objectEntries(tools), ([toolName]) => !blockedToolNames.has(toolName)), + return recordFromEntries( + filterValues(ownEntries(tools), ([toolName]) => !blockedToolNames.has(toolName)), ); } @@ -317,7 +363,12 @@ function resolveOwnerScopedToolName(input: { return input.toolName; } - for (const [registeredName, tool] of objectEntries(input.localTools)) { + const entries = ownEntries(input.localTools); + for (let index = 0; index < entries.length; index++) { + const pair = entries[index]; + if (pair === undefined) continue; + const registeredName = pair[0]; + const tool = pair[1]; if ( tool.ownerAgentId === input.agentId && tool.shortName === input.toolName @@ -361,12 +412,12 @@ export function filterHostedChatRuntimeLocalTools(input: { sourceProviderToolNames?: readonly string[]; }): HostToolSet { const allowedToolNames = normalizeHostedRuntimeAllowedToolNames(input.allowedToolNames); - const entries = filter( - objectEntries(input.tools), + const entries = filterValues( + ownEntries(input.tools), ([toolName]) => allowedToolNames ? allowedToolNames.has(toolName) : true, ); - return objectFromEntries(entries.sort(([left], [right]) => compareStrings(left, right))); + return recordFromEntries(sortValues(entries, ([left], [right]) => compareStrings(left, right))); } function shouldIncludeHostedWebFetchFallback(input: { @@ -376,10 +427,10 @@ function shouldIncludeHostedWebFetchFallback(input: { allowedProviderToolNames: ReadonlySet | null; providerNativeToolNames: readonly string[]; }): boolean { - if (!objectHasOwn(input.localTools, "web_fetch")) { + if (!hasOwn(input.localTools, "web_fetch")) { return false; } - if (input.providerNativeToolNames.includes("web_fetch")) { + if (includesValue(input.providerNativeToolNames, "web_fetch")) { return false; } if (input.allowedProviderToolNames !== null) { @@ -413,7 +464,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ); const allowedToolNames = resolveHostedRuntimeAllowedToolNames({ allowedToolNames: normalizedAllowedToolNames, - localToolNames: objectKeys(authorizedLocalTools), + localToolNames: ownKeys(authorizedLocalTools), availableSkillIds: input.taskContext.availableSkillIds, configDerivedSelector: configDerivedSelector || (input.includeRuntimeEssentialToolsWhenEmpty === true && @@ -433,12 +484,12 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< input.allowedProviderToolNames, ); const providerNativeToolNames = getProviderNativeToolNames({ model: input.taskContext.model }); - const sortedLocalToolEntries = filter( - objectEntries(selectedLocalTools), + const sortedLocalToolEntries = filterValues( + ownEntries(selectedLocalTools), ([toolName]) => isIntegrationToolAllowedBySourcePolicy(toolName, input.sourceIntegrationPolicy), ); if ( - !objectHasOwn(selectedLocalTools, "web_fetch") && + !hasOwn(selectedLocalTools, "web_fetch") && shouldIncludeHostedWebFetchFallback({ localTools: postFormInputLocalTools, sourceProviderToolNames, @@ -449,11 +500,11 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ) { const hostedWebFetchTool = postFormInputLocalTools.web_fetch; if (hostedWebFetchTool !== undefined) { - sortedLocalToolEntries.push(["web_fetch", hostedWebFetchTool]); + sortedLocalToolEntries[sortedLocalToolEntries.length] = ["web_fetch", hostedWebFetchTool]; } } - const sortedLocalTools = objectFromEntries( - sortedLocalToolEntries.sort(([left], [right]) => compareStrings(left, right)), + const sortedLocalTools = recordFromEntries( + sortValues(sortedLocalToolEntries, ([left], [right]) => compareStrings(left, right)), ); const localHostTools = input.traceLocalTools ? traceHostTools(sortedLocalTools, input.traceLocalTools) @@ -464,7 +515,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const remoteToolSources = withoutDeniedRemoteTools( "remoteToolSources" in input - ? input.remoteToolSources.map((source) => + ? mapValues(input.remoteToolSources, (source) => createHostedProjectRemoteToolSource({ source: withoutDeniedRemoteTool( wrapRemoteToolSourceWithMcpPolicy( @@ -481,8 +532,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< shouldRetryWithTool: input.shouldRetryWithRemoteTool, onProjectSwitch: input.onStudioProjectSwitch, onSteeringMutation: input.onSteeringMutation, - }) - ) + })) : createHostedProjectRemoteToolSources({ authToken: input.taskContext.authToken, apiMcpUrl: input.apiMcpUrl, @@ -523,13 +573,17 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< input.sourceIntegrationPolicy, ); const localProviderToolNames = new Set( - objectKeys(sortedLocalTools).filter((toolName) => providerNativeToolNames.includes(toolName)), + filterValues( + ownKeys(sortedLocalTools), + (toolName) => includesValue(providerNativeToolNames, toolName), + ), ); // Explicit denials also bind provider-native tools: a denied name must not // reach the model through the provider channel after the host and remote // paths filtered it out. const deniedProviderToolNames = new Set(input.deniedToolNames ?? []); - const selectedProviderToolNames = providerNativeToolNames.filter( + const selectedProviderToolNames = filterValues( + providerNativeToolNames, (toolName) => !deniedProviderToolNames.has(toolName) && !localProviderToolNames.has(toolName) && @@ -546,7 +600,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< // Materialize before validation and provider capping so skipped descriptors // cannot advertise capabilities that the runtime cannot execute. const localRuntimeTools = createToolsFromHostDefinitions(localHostTools); - const localToolNames = objectKeys(localRuntimeTools); + const localToolNames = ownKeys(localRuntimeTools); const toolSearchDenied = deniedProviderToolNames.has(TOOL_SEARCH_TOOL_NAME); const toolLoadingMode: RuntimeToolLoadingMode = normalizedAllowedToolNames === null && !toolSearchDenied @@ -554,7 +608,8 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< : "eager"; const authorizedToolNames = [ ...new Set([...localToolNames, ...providerToolNames, ...remoteToolNames]), - ].sort(compareStrings); + ]; + sortValues(authorizedToolNames, compareStrings); // Deferred mode sends only bootstrap/search plus explicitly loaded schemas to // the model, so the provider schema limit must not truncate its searchable or // executable authorization catalog. Eager mode still needs an up-front cap. @@ -567,23 +622,30 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const compatibleToolNames = new Set(availableToolNames); const compatibleLocalRuntimeTools = toolLoadingMode === "deferred" ? localRuntimeTools - : objectFromEntries( - filter(objectEntries(localRuntimeTools), ([toolName]) => compatibleToolNames.has(toolName)), + : recordFromEntries( + filterValues(ownEntries(localRuntimeTools), ([toolName]) => + compatibleToolNames.has(toolName)), ); - const compatibleLocalToolNames = objectKeys(compatibleLocalRuntimeTools); + const compatibleLocalToolNames = ownKeys(compatibleLocalRuntimeTools); const compatibleRemoteToolNames = toolLoadingMode === "deferred" ? remoteToolNames - : remoteToolNames.filter((toolName) => compatibleToolNames.has(toolName)); + : filterValues(remoteToolNames, (toolName) => compatibleToolNames.has(toolName)); const compatibleProviderToolNames = toolLoadingMode === "deferred" ? providerToolNames - : providerToolNames.filter((toolName) => compatibleToolNames.has(toolName)); - const bootstrapToolNames = availableToolNames.filter((toolName) => toolName === "load_skill"); + : filterValues(providerToolNames, (toolName) => compatibleToolNames.has(toolName)); + const bootstrapToolNames = filterValues( + availableToolNames, + (toolName) => toolName === "load_skill", + ); const hasDeferredTools = availableToolNames.length > bootstrapToolNames.length; const modelVisibleToolNames = toolLoadingMode === "deferred" - ? [ - ...bootstrapToolNames, - ...(hasDeferredTools && !toolSearchDenied ? [TOOL_SEARCH_TOOL_NAME] : []), - ].sort(compareStrings) + ? sortValues( + [ + ...bootstrapToolNames, + ...(hasDeferredTools && !toolSearchDenied ? [TOOL_SEARCH_TOOL_NAME] : []), + ], + compareStrings, + ) : availableToolNames; input.taskContext.availableToolNames = modelVisibleToolNames; diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 8ae591203e..692850e23e 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -64,6 +64,31 @@ import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; const apply = Reflect.apply; const TypeErrorConstructor = TypeError; +const objectEntries = Object.entries; +const objectDefineProperty = Object.defineProperty; + +function mapOwnRecord( + input: Record, + mapper: (name: string, value: TInput) => TOutput, +): Record { + const entries = apply(objectEntries, Object, [input]) as Array<[string, TInput]>; + const output: Record = {}; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (entry === undefined) continue; + apply(objectDefineProperty, Object, [ + output, + entry[0], + { + value: mapper(entry[0], entry[1]), + enumerable: true, + configurable: true, + writable: true, + }, + ]); + } + return output; +} /** Configuration used by default hosted chat runtime. */ export type DefaultHostedChatRuntimeConfig = { @@ -302,11 +327,9 @@ function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): Age const liveProjectSteering = input.options.liveProjectSteering; const refreshSystem = input.refreshSystem; - const runtimeTools = Object.fromEntries( - Object.entries(input.toolAssembly.runtimeTools).map(([toolName, runtimeTool]) => [ - toolName, - markRuntimeLocalTool(runtimeTool), - ]), + const runtimeTools = mapOwnRecord( + input.toolAssembly.runtimeTools, + (_toolName, runtimeTool) => markRuntimeLocalTool(runtimeTool), ); const resolveHostedRuntimeState = createHostedRuntimeStateResolver({ taskContext: input.taskContext, @@ -413,27 +436,25 @@ function snapshotHostedToolResult(result: unknown): unknown { /** @internal Bound tool results and sanitize errors without trusted provenance. */ export function scopeHostedRuntimeToolResults(tools: ToolSet): ToolSet { - return Object.fromEntries( - Object.entries(tools).map(([toolName, tool]) => { + return mapOwnRecord( + tools, + (_toolName, tool) => { const execute = tool.execute; const preserveTrustedError = hasTrustedHostToolProvenance(tool); - return [ - toolName, - { - ...tool, - execute: async (toolInput: unknown, context?: ToolExecutionContext) => { - try { - return snapshotHostedToolResult( - await apply(execute, tool, [toolInput, context]), - ); - } catch (error) { - if (preserveTrustedError) throw error; - throw new TypeErrorConstructor("Hosted project tool execution failed"); - } - }, + return { + ...tool, + execute: async (toolInput: unknown, context?: ToolExecutionContext) => { + try { + return snapshotHostedToolResult( + await apply(execute, tool, [toolInput, context]), + ); + } catch (error) { + if (preserveTrustedError) throw error; + throw new TypeErrorConstructor("Hosted project tool execution failed"); + } }, - ]; - }), + }; + }, ); } @@ -444,19 +465,17 @@ export function scopeHostedRuntimeTools(input: { cloudContext: VeryfrontCloudContext; }): ToolSet { const scopedTools = scopeHostedRuntimeToolResults(input.tools); - return Object.fromEntries( - Object.entries(scopedTools).map(([toolName, tool]) => [ - toolName, - { - ...tool, - execute: (toolInput: unknown, context?: ToolExecutionContext) => - withoutHostedCredentials({ - taskContext: input.taskContext, - cloudContext: input.cloudContext, - operation: () => apply(tool.execute, tool, [toolInput, context]), - }), - }, - ]), + return mapOwnRecord( + scopedTools, + (_toolName, tool) => ({ + ...tool, + execute: (toolInput: unknown, context?: ToolExecutionContext) => + withoutHostedCredentials({ + taskContext: input.taskContext, + cloudContext: input.cloudContext, + operation: () => apply(tool.execute, tool, [toolInput, context]), + }), + }), ); } diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index b0691253c6..e1558e4a92 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -705,6 +705,40 @@ describe("executor runtime preparation review regressions", () => { } }); } + it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { + const originalEntries = Object.entries; + let hiddenExecutions = 0; + const hidden = { + ...syntheticHostTool(), + execute: () => { + hiddenExecutions++; + return { ok: true }; + }, + }; + const f = fixture({ + grant: { ...grant, allowedToolNames: ["visible"], hostToolFacadeIds: ["local"] }, + facades: { + hostTools: new Map([["local", { visible: syntheticHostTool(), hidden }]]), + }, + load: () => { + Object.entries = ((value: object) => { + const entries = Reflect.apply(originalEntries, Object, [value]); + for (let index = 0; index < entries.length; index++) { + if (entries[index]?.[1] === hidden) hidden.execute(); + } + return entries; + }) as typeof Object.entries; + return Promise.resolve(runtime()); + }, + }); + try { + assertEquals((await prepare(f.owner) as { ok?: boolean }).ok, true); + assertEquals(hiddenExecutions, 0); + } finally { + Object.entries = originalEntries; + await f.owner.close(); + } + }); for (const cancellation of ["operation", "owner"] as const) { it(`cancels steering refresh on ${cancellation} abort and joins it before cleanup`, async () => { diff --git a/src/tool/host-tools.ts b/src/tool/host-tools.ts index be8f2eb7f0..9d7c384e23 100644 --- a/src/tool/host-tools.ts +++ b/src/tool/host-tools.ts @@ -5,6 +5,10 @@ import type { Tool, ToolConfig, ToolExecutionContext, ToolSet } from "./types.ts import { getRemoteToolProvenance, markRemoteToolProvenance } from "./remote-tool-provenance.ts"; import { inheritTrustedHostToolProvenance } from "./host-tool-provenance.ts"; +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const objectEntries = Object.entries; + type HostToolExecute = { bivarianceHack: (input: unknown, options?: ToolExecutionContext) => Promise | unknown; }["bivarianceHack"]; @@ -41,7 +45,7 @@ export interface HostToolMaterializationOptions { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !arrayIsArray(value); } /** Detect a contract `Schema` value produced by defineSchema. */ @@ -110,7 +114,12 @@ export function createToolsFromHostDefinitions( ): ToolSet { const tools: ToolSet = {}; - for (const [toolName, definition] of Object.entries(definitions)) { + const entries = apply(objectEntries, Object, [definitions]) as Array<[string, unknown]>; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (entry === undefined) continue; + const toolName = entry[0]; + const definition = entry[1]; if (!isHostToolDefinition(definition)) continue; const execute = async (input: unknown, context: ToolExecutionContext | undefined) => From d14c5c2f5c4ce02b6ab48521bb36b33ae01d427f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:29:25 +0200 Subject: [PATCH 051/194] fix(agent): isolate selector set operations from mutable prototypes --- .../hosted/chat-runtime-tool-assembly.ts | 23 +++++----- src/agent/hosted/executor-runtime-prepare.ts | 5 ++- src/agent/hosted/runtime-essential-tools.ts | 9 ++-- src/agent/hosted/runtime-request-config.ts | 17 ++++--- src/security/private-set.test.ts | 28 ++++++++++++ src/security/private-set.ts | 45 +++++++++++++++++++ 6 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 src/security/private-set.test.ts create mode 100644 src/security/private-set.ts diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index ae42ce0c39..a89d09f387 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -1,3 +1,4 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; import { createRemoteMCPToolSource, @@ -255,8 +256,8 @@ export function augmentVeryfrontApiMcpServerPolicy( if (server.kind !== "veryfront-api" || !server.toolPolicy?.allow) { return server; } - const denied = new Set(server.toolPolicy.deny ?? []); - const allow = new Set(server.toolPolicy.allow); + const denied = createPrivateSet(server.toolPolicy.deny ?? []); + const allow = createPrivateSet(server.toolPolicy.allow); for (const toolName of integrationToolNames) { if (!denied.has(toolName)) allow.add(toolName); } @@ -274,7 +275,7 @@ function withoutDeniedHostTools( if (!deniedToolNames?.length) { return tools; } - const denied = new Set(deniedToolNames); + const denied = createPrivateSet(deniedToolNames); return recordFromEntries( filterValues(ownEntries(tools), ([toolName, tool]) => !denied.has(toolName) && @@ -314,7 +315,7 @@ function applyHostedHostToolPolicy( if (policy === undefined) { return tools; } - const allowed = new Set(policy.allow); + const allowed = createPrivateSet(policy.allow); return recordFromEntries( filterValues(ownEntries(tools), ([registeredName, tool]) => allowed.has(registeredName) || @@ -348,7 +349,7 @@ function filterPostFormInputLocalTools( return tools; } - const blockedToolNames = new Set(["form_input", "load_skill"]); + const blockedToolNames = createPrivateSet(["form_input", "load_skill"]); return recordFromEntries( filterValues(ownEntries(tools), ([toolName]) => !blockedToolNames.has(toolName)), ); @@ -391,7 +392,7 @@ export function resolveOwnerScopedToolNames(input: { return input.toolNames; } - const resolvedToolNames = new Set(); + const resolvedToolNames = createPrivateSet(); for (const toolName of toolNames) { resolvedToolNames.add( resolveOwnerScopedToolName({ @@ -479,7 +480,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< allowedToolNames, sourceProviderToolNames: input.sourceProviderToolNames, }); - const sourceProviderToolNames = new Set(input.sourceProviderToolNames ?? []); + const sourceProviderToolNames = createPrivateSet(input.sourceProviderToolNames ?? []); const allowedProviderToolNames = normalizeHostedRuntimeAllowedToolNames( input.allowedProviderToolNames, ); @@ -572,7 +573,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< listedRemoteToolNames, input.sourceIntegrationPolicy, ); - const localProviderToolNames = new Set( + const localProviderToolNames = createPrivateSet( filterValues( ownKeys(sortedLocalTools), (toolName) => includesValue(providerNativeToolNames, toolName), @@ -581,7 +582,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< // Explicit denials also bind provider-native tools: a denied name must not // reach the model through the provider channel after the host and remote // paths filtered it out. - const deniedProviderToolNames = new Set(input.deniedToolNames ?? []); + const deniedProviderToolNames = createPrivateSet(input.deniedToolNames ?? []); const selectedProviderToolNames = filterValues( providerNativeToolNames, (toolName) => @@ -607,7 +608,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ? "deferred" : "eager"; const authorizedToolNames = [ - ...new Set([...localToolNames, ...providerToolNames, ...remoteToolNames]), + ...createPrivateSet([...localToolNames, ...providerToolNames, ...remoteToolNames]), ]; sortValues(authorizedToolNames, compareStrings); // Deferred mode sends only bootstrap/search plus explicitly loaded schemas to @@ -619,7 +620,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< model: input.taskContext.model, requiredToolNames: localToolNames, }); - const compatibleToolNames = new Set(availableToolNames); + const compatibleToolNames = createPrivateSet(availableToolNames); const compatibleLocalRuntimeTools = toolLoadingMode === "deferred" ? localRuntimeTools : recordFromEntries( diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 2699c63243..494b5b02c0 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -1,3 +1,4 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { VERYFRONT_CLOUD_MODEL_PREFIX } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; @@ -151,7 +152,7 @@ function snapshotGrant( !model.id.startsWith(VERYFRONT_CLOUD_MODEL_PREFIX) || model.id.length === VERYFRONT_CLOUD_MODEL_PREFIX.length ) || !parsed.models.some((model) => model.id === parsed.defaultModelId) || - new Set(parsed.models.map((model) => model.id)).size !== parsed.models.length + createPrivateSet(parsed.models.map((model) => model.id)).size !== parsed.models.length ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); return parsed; } @@ -336,7 +337,7 @@ export function createExecutorRuntimePreparation(input: Options) { })!, ]; const deniedToolNames = [ - ...new Set([ + ...createPrivateSet([ ...definition.deniedTools ?? [], ...normalizeToolNames(definition.deniedTools ?? []), ]), diff --git a/src/agent/hosted/runtime-essential-tools.ts b/src/agent/hosted/runtime-essential-tools.ts index 20877d987e..80de6cd2d4 100644 --- a/src/agent/hosted/runtime-essential-tools.ts +++ b/src/agent/hosted/runtime-essential-tools.ts @@ -1,3 +1,4 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; /** Public API contract for hosted runtime allowed tool names. */ export type HostedRuntimeAllowedToolNames = readonly string[] | ReadonlySet | null; @@ -37,7 +38,7 @@ export function normalizeHostedRuntimeAllowedToolNames( return null; } - return new Set(toolNames); + return createPrivateSet(toolNames); } /** Resolve allowed tools after applying runtime-essential hosted tool policy. */ @@ -45,7 +46,7 @@ export function resolveHostedRuntimeAllowedToolNames( input: ResolveHostedRuntimeAllowedToolNamesInput, ): ReadonlySet | null { const allowedToolNames = normalizeHostedRuntimeAllowedToolNames(input.allowedToolNames); - const localToolNames = new Set(input.localToolNames); + const localToolNames = createPrivateSet(input.localToolNames); const hasKnownSkillManifest = input.availableSkillIds !== undefined; const hasAuthorizedSkills = (input.availableSkillIds?.length ?? 0) > 0; @@ -54,7 +55,7 @@ export function resolveHostedRuntimeAllowedToolNames( return null; } - const resolvedToolNames = new Set(localToolNames); + const resolvedToolNames = createPrivateSet(localToolNames); for (const toolName of EMPTY_SKILL_MANIFEST_TOOL_NAMES) { resolvedToolNames.delete(toolName); } @@ -65,7 +66,7 @@ export function resolveHostedRuntimeAllowedToolNames( return allowedToolNames; } - const resolvedToolNames = new Set(allowedToolNames); + const resolvedToolNames = createPrivateSet(allowedToolNames); if (hasKnownSkillManifest && !hasAuthorizedSkills) { for (const toolName of EMPTY_SKILL_MANIFEST_TOOL_NAMES) { diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 89cd1b23fa..773f02aa7f 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -1,3 +1,4 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import type { ChatRuntimeOverrides } from "../../chat/types.ts"; import { type HostedChatRequest, hostedChatRuntimeOverridesSchema } from "./chat-request.ts"; import type { @@ -81,7 +82,7 @@ export function getServerResolvedToolExposureCheckpoint( !isSupportedToolExposureCheckpointVersion(value.version) || !Array.isArray(value.loadedToolNames) || !value.loadedToolNames.every((name) => typeof name === "string" && name.length > 0) || - new Set(value.loadedToolNames).size !== value.loadedToolNames.length + createPrivateSet(value.loadedToolNames).size !== value.loadedToolNames.length ) { return undefined; } @@ -213,10 +214,12 @@ export function resolveHostedRuntimeAllowedTools(input: { }): string[] | undefined { if (input.configuredTools === true) { if (input.configuredDeniedTools?.length) return []; - return input.requestedTools === undefined ? undefined : [...new Set(input.requestedTools)]; + return input.requestedTools === undefined + ? undefined + : [...createPrivateSet(input.requestedTools)]; } - const configuredToolNames = new Set([ + const configuredToolNames = createPrivateSet([ ...(input.configuredTools ?? []), ...(input.configuredDelegates ?? []).map((id) => `${AGENT_DELEGATE_TOOL_PREFIX}${id}`), ]); @@ -227,7 +230,7 @@ export function resolveHostedRuntimeAllowedTools(input: { const hasImplicitLegacyDelegation = input.configuredSkills === undefined || input.configuredSkills === true || (Array.isArray(input.configuredSkills) && input.configuredSkills.length > 0); - return [...new Set(input.requestedTools)].filter((toolName) => + return [...createPrivateSet(input.requestedTools)].filter((toolName) => configuredToolNames.has(toolName) || (toolName === "invoke_agent" && hasImplicitLegacyDelegation) ); @@ -238,12 +241,14 @@ export function resolveHostedRuntimeAllowedProviderTools(input: { configuredProviderTools: RuntimeAgentMarkdownDefinition["providerTools"]; requestedTools: string[] | undefined; }): string[] { - const configuredToolNames = new Set(input.configuredProviderTools ?? []); + const configuredToolNames = createPrivateSet(input.configuredProviderTools ?? []); if (input.requestedTools === undefined) { return [...configuredToolNames]; } - return [...new Set(input.requestedTools)].filter((toolName) => configuredToolNames.has(toolName)); + return [...createPrivateSet(input.requestedTools)].filter((toolName) => + configuredToolNames.has(toolName) + ); } /** Configuration used by resolve hosted runtime request. */ diff --git a/src/security/private-set.test.ts b/src/security/private-set.test.ts new file mode 100644 index 0000000000..c17872bdd7 --- /dev/null +++ b/src/security/private-set.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createPrivateSet } from "./private-set.ts"; + +describe("private selector sets", () => { + it("snapshots caller-owned arrays and sets without sharing later membership changes", () => { + const source = ["read_file", "read_file"]; + const first = createPrivateSet(source); + source.push("update_file"); + const second = createPrivateSet(first); + first.add("invoke_agent"); + assertEquals([...second], ["read_file"]); + assertEquals(second.has("update_file"), false); + assertEquals(second.has("invoke_agent"), false); + assertEquals(second.size, 1); + }); + + it("keeps captured membership and iteration bound to their owning set", () => { + const names = createPrivateSet(["read_file"]); + const { add, has, delete: remove, values } = names; + assertEquals(has("read_file"), true); + add("update_file"); + assertEquals([...values()], ["read_file", "update_file"]); + assertEquals(remove("read_file"), true); + assertEquals(has("read_file"), false); + assertEquals(names.size, 1); + }); +}); diff --git a/src/security/private-set.ts b/src/security/private-set.ts new file mode 100644 index 0000000000..1e02f649fc --- /dev/null +++ b/src/security/private-set.ts @@ -0,0 +1,45 @@ +const SetConstructor = Set; +const apply = Reflect.apply; +const defineProperties = Object.defineProperties; +const freeze = Object.freeze; +const isArray = Array.isArray; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const setDelete = Set.prototype.delete; +const setValues = Set.prototype.values; +const iteratorSymbol: typeof Symbol.iterator = Symbol.iterator; +const iteratorNext = Object.getPrototypeOf(new SetConstructor().values()).next; +const setSize = Object.getOwnPropertyDescriptor(Set.prototype, "size")!.get!; + +/** Internal selector set whose used operations do not consult mutable prototypes. */ +export function createPrivateSet(values?: Iterable): Set { + const set = new SetConstructor(); + const add = (value: T) => { + apply(setAdd, set, [value]); + return set; + }; + const iterate = (): IterableIterator => { + const iterator = apply(setValues, set, []); + return freeze({ + next: () => apply(iteratorNext, iterator, []) as IteratorResult, + [iteratorSymbol]() { + return this; + }, + }); + }; + defineProperties(set, { + add: { value: add }, + has: { value: (value: T) => apply(setHas, set, [value]) as boolean }, + delete: { value: (value: T) => apply(setDelete, set, [value]) as boolean }, + size: { get: () => apply(setSize, set, []) as number }, + values: { value: iterate }, + keys: { value: iterate }, + [iteratorSymbol]: { value: iterate }, + }); + if (isArray(values)) { + for (let index = 0; index < values.length; index++) add(values[index]); + } else if (values) { + for (const value of values) add(value); + } + return freeze(set); +} From 6db5a2694cf1177187a8e7a35120e290bc9f4563 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:36:47 +0200 Subject: [PATCH 052/194] test(agent): move private facade reflection check to integration --- .../hosted/executor-runtime-prepare.test.ts | 34 ------ .../executor-runtime-private-facades.test.ts | 113 ++++++++++++++++++ 2 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 tests/integration/agent/executor-runtime-private-facades.test.ts diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index e1558e4a92..b0691253c6 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -705,40 +705,6 @@ describe("executor runtime preparation review regressions", () => { } }); } - it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { - const originalEntries = Object.entries; - let hiddenExecutions = 0; - const hidden = { - ...syntheticHostTool(), - execute: () => { - hiddenExecutions++; - return { ok: true }; - }, - }; - const f = fixture({ - grant: { ...grant, allowedToolNames: ["visible"], hostToolFacadeIds: ["local"] }, - facades: { - hostTools: new Map([["local", { visible: syntheticHostTool(), hidden }]]), - }, - load: () => { - Object.entries = ((value: object) => { - const entries = Reflect.apply(originalEntries, Object, [value]); - for (let index = 0; index < entries.length; index++) { - if (entries[index]?.[1] === hidden) hidden.execute(); - } - return entries; - }) as typeof Object.entries; - return Promise.resolve(runtime()); - }, - }); - try { - assertEquals((await prepare(f.owner) as { ok?: boolean }).ok, true); - assertEquals(hiddenExecutions, 0); - } finally { - Object.entries = originalEntries; - await f.owner.close(); - } - }); for (const cancellation of ["operation", "owner"] as const) { it(`cancels steering refresh on ${cancellation} abort and joins it before cleanup`, async () => { diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts new file mode 100644 index 0000000000..8fdb7267af --- /dev/null +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -0,0 +1,113 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { agent } from "#veryfront/agent/factory.ts"; +import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; +import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; +import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; + +const binding = { + allocationId: "reflection-allocation", + invocationId: "reflection-invocation", + generation: 1, +}; +const source = { type: "release", releaseId: "synthetic-release" } as const; +const modelId = "veryfront-cloud/openai/gpt-5.4"; + +it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { + const originalEntries = Object.entries; + let hiddenExecutions = 0; + const visible = { + description: "Synthetic tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }; + const hidden = { + ...visible, + execute: () => { + hiddenExecutions++; + return { ok: true }; + }, + }; + const coder = agent({ + id: "coder", + system: "Synthetic instructions", + model: modelId, + tools: true, + }); + const runtime: ProjectAgentRuntimeDiscovery = { + agents: new Map([[coder.id, coder]]), + tools: new Map(), + 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, + projectDir: "/synthetic-project", + signal: new AbortController().signal, + backend: { + load: () => { + Object.entries = ((value: object) => { + const entries = Reflect.apply(originalEntries, Object, [value]); + for (let index = 0; index < entries.length; index++) { + if (entries[index]?.[1] === hidden) hidden.execute(); + } + return entries; + }) as typeof Object.entries; + return Promise.resolve(runtime); + }, + cleanup: () => Promise.resolve(), + }, + }); + const owner = createExecutorRuntimePreparation({ + binding, + source, + discovery, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: ["visible"], + hostToolFacadeIds: ["local"], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, + }, + facades: { + hostTools: new Map([["local", { visible, hidden }]]), + remoteToolSources: new Map(), + resolveModelRuntime: () => ({ + modelId: "gpt-5.4", + provider: "openai", + specificationVersion: "v3", + doGenerate: () => Promise.reject(new Error("Unexpected generate call")), + doStream: () => Promise.reject(new Error("Unexpected stream call")), + }), + cleanup: () => Promise.resolve(), + }, + }); + try { + const operation = owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + const result = await operation.handle({ agentId: "coder" }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }); + assertEquals((result as { ok?: boolean }).ok, true); + assertEquals(hiddenExecutions, 0); + } finally { + Object.entries = originalEntries; + await owner.close(); + } +}); From 58c66de4ba13b9ba65d80be12db5084d69d3e880 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:39:33 +0200 Subject: [PATCH 053/194] fix(agent): protect model grants and retained runtime tasks --- src/agent/hosted/executor-discovery.ts | 3 ++- src/agent/hosted/executor-runtime-prepare.ts | 26 +++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 8c3f24c2bf..51e35c2561 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -1,4 +1,5 @@ import { isAbsolute, join, relative, sep } from "node:path"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ProjectAgentRuntimeAgentSource, @@ -91,7 +92,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut let runtime: ProjectAgentRuntimeDiscovery | undefined; let closing: Promise | undefined; let cleanupStarted = false; - const runtimeTasks = new Set>(); + const runtimeTasks = createPrivateSet>(); const definitions = new Map(); const helpers = () => import("#veryfront/agent/project/agent-runtime.ts"); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 494b5b02c0..4a3c73af7b 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -75,6 +75,8 @@ const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; const arrayFilter = Array.prototype.filter; const arrayIncludes = Array.prototype.includes; +const arrayMap = Array.prototype.map; +const arrayReduce = Array.prototype.reduce; function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { return apply(arrayFilter, values, [predicate]) as T[]; @@ -82,6 +84,12 @@ function filter(values: readonly T[], predicate: (value: T) => boolean): T[] function includes(values: readonly T[], value: T): boolean { return apply(arrayIncludes, values, [value]) as boolean; } +function map(values: readonly T[], callback: (value: T) => U): U[] { + return apply(arrayMap, values, [callback]) as U[]; +} +function reduce(values: readonly T[], callback: (result: U, value: T) => U, initial: U): U { + return apply(arrayReduce, values, [callback, initial]) as U; +} function privateMapGet(map: ReadonlyMap, key: K): V | undefined { return apply(mapGet, map, [key]) as V | undefined; @@ -180,6 +188,7 @@ function intersectNames( */ 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); const facades: ExecutorRuntimeFacades = { @@ -306,7 +315,7 @@ export function createExecutorRuntimePreparation(input: Options) { ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); const definition = described.value.definition; const modelId = request.modelId ?? grant.defaultModelId; - const modelGrant = grant.models.find((model) => model.id === modelId); + const modelGrant = privateMapGet(modelGrants, modelId); if ( !modelGrant || (request.maxSteps !== undefined && request.maxSteps > grant.maxSteps) || (request.maxOutputTokens !== undefined && @@ -371,7 +380,7 @@ export function createExecutorRuntimePreparation(input: Options) { ); const resolveModelRuntime: AgentModelRuntimeResolver = (id) => { assertActive(); - if (!grant.models.some((entry) => entry.id === id)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + if (!privateMapHas(modelGrants, id)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); return facades.resolveModelRuntime(id) ?? refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); }; registerModelRuntimeResolverRevoker( @@ -504,13 +513,12 @@ export function createExecutorRuntimePreparation(input: Options) { taskContext, error, }), - remoteToolSources: grant.remoteToolSourceIds.map((id) => - filter(definition.mcpServers ?? [], (server) => (server.id ?? server.kind) === id) - .reduce( - (source, server) => wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy), - privateMapGet(facades.remoteToolSources, id)!, - ) - ), + remoteToolSources: map(grant.remoteToolSourceIds, (id) => + reduce( + filter(definition.mcpServers ?? [], (server) => (server.id ?? server.kind) === id), + (source, server) => wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy), + privateMapGet(facades.remoteToolSources, id)!, + )), onSteeringMutation: (mutation) => { if (mutation.instructionsChanged || mutation.skillsChanged) { incrementSteeringRevision(taskContext); From fd03b58f2829090e819baeba286c08f9485022e7 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 14:36:43 +0200 Subject: [PATCH 054/194] fix(agent): isolate private facade selection --- .../hosted/executor-runtime-prepare.test.ts | 1 - src/agent/hosted/executor-runtime-prepare.ts | 54 ++++++++++++++++--- .../executor-runtime-private-facades.test.ts | 38 +++++++++++-- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index b0691253c6..b029ca53e0 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -705,7 +705,6 @@ describe("executor runtime preparation review regressions", () => { } }); } - for (const cancellation of ["operation", "owner"] as const) { it(`cancels steering refresh on ${cancellation} abort and joins it before cleanup`, async () => { const entered = Promise.withResolvers(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 4a3c73af7b..505cafce2a 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -73,10 +73,14 @@ const apply = Reflect.apply; const mapGet = Map.prototype.get; const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; +const objectDefineProperty = Object.defineProperty; +const objectEntries = Object.entries; const arrayFilter = Array.prototype.filter; const arrayIncludes = Array.prototype.includes; const arrayMap = Array.prototype.map; const arrayReduce = Array.prototype.reduce; +const IntrinsicSet = Set; +const setHas = Set.prototype.has; function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { return apply(arrayFilter, values, [predicate]) as T[]; @@ -98,6 +102,27 @@ function privateMapHas(map: ReadonlyMap, key: K): boolean { return apply(mapHas, map, [key]) as boolean; } +function selectAllowedHostTools( + tools: HostToolSet, + allowedNames: readonly string[], +): HostToolSet { + const allowed = new IntrinsicSet(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 || !apply(setHas, allowed, [entry[0]])) continue; + apply(objectDefineProperty, Object, [ + selected, + entry[0], + { value: entry[1], enumerable: true, configurable: true, writable: true }, + ]); + } + return selected; +} + type CreationOptions = HostedChatRuntimeCreationOptions< RuntimeAgentMarkdownDefinition, RuntimeAgentThinkingConfig @@ -493,11 +518,31 @@ export function createExecutorRuntimePreparation(input: Options) { } : {}), }; + 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, + ); + } + } + remoteToolSources[remoteToolSources.length] = remoteToolSource; + } const toolAssembly = await prepareFacadedHostedChatRuntimeToolAssembly({ signal: context.signal, taskContext, instructions: options.instructions, - localTools, + localTools: selectAllowedHostTools(localTools, allowedToolNames), sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, hostToolPolicy: { allow: allowedToolNames }, allowedToolNames, @@ -513,12 +558,7 @@ export function createExecutorRuntimePreparation(input: Options) { taskContext, error, }), - remoteToolSources: map(grant.remoteToolSourceIds, (id) => - reduce( - filter(definition.mcpServers ?? [], (server) => (server.id ?? server.kind) === id), - (source, server) => wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy), - privateMapGet(facades.remoteToolSources, id)!, - )), + remoteToolSources, onSteeringMutation: (mutation) => { if (mutation.instructionsChanged || mutation.skillsChanged) { incrementSteeringRevision(taskContext); diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 8fdb7267af..d3d09064e9 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -17,7 +17,10 @@ const modelId = "veryfront-cloud/openai/gpt-5.4"; it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { const originalEntries = Object.entries; + const originalSetHas = Set.prototype.has; + const originalReduce = Array.prototype.reduce; let hiddenExecutions = 0; + let remoteExecutions = 0; const visible = { description: "Synthetic tool", inputSchema: defineSchema((v) => v.object({}))(), @@ -35,7 +38,16 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", system: "Synthetic instructions", model: modelId, tools: true, + mcpServers: [{ kind: "veryfront-api", id: "api" }], }); + const remote = { + id: "api", + listTools: () => Promise.resolve([]), + executeTool: () => { + remoteExecutions++; + return Promise.resolve({ ok: true }); + }, + }; const runtime: ProjectAgentRuntimeDiscovery = { agents: new Map([[coder.id, coder]]), tools: new Map(), @@ -64,6 +76,23 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", } return entries; }) as typeof Object.entries; + Set.prototype.has = function (value: unknown) { + if ( + value === "hidden" && + Reflect.apply(originalSetHas, this, ["visible"]) === true + ) { + return true; + } + return Reflect.apply(originalSetHas, this, [value]); + }; + Array.prototype.reduce = (function ( + this: unknown[], + callback: (...args: unknown[]) => unknown, + ...initial: unknown[] + ) { + if (initial[0] === remote) void remote.executeTool(); + return Reflect.apply(originalReduce, this, [callback, ...initial]); + }) as typeof Array.prototype.reduce; return Promise.resolve(runtime); }, cleanup: () => Promise.resolve(), @@ -80,12 +109,12 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), allowedToolNames: ["visible"], hostToolFacadeIds: ["local"], - remoteToolSourceIds: [], + remoteToolSourceIds: ["api"], execution: { kind: "ephemeral", projectId: null }, }, facades: { hostTools: new Map([["local", { visible, hidden }]]), - remoteToolSources: new Map(), + remoteToolSources: new Map([["api", remote]]), resolveModelRuntime: () => ({ modelId: "gpt-5.4", provider: "openai", @@ -104,10 +133,13 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", signal: new AbortController().signal, deadline: Date.now() + 30_000, }); - assertEquals((result as { ok?: boolean }).ok, true); + assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); assertEquals(hiddenExecutions, 0); + assertEquals(remoteExecutions, 0); } finally { Object.entries = originalEntries; + Set.prototype.has = originalSetHas; + Array.prototype.reduce = originalReduce; await owner.close(); } }); From 9eb666866d5e913608a73ff036cfe4c8bc57fe69 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:43:52 +0200 Subject: [PATCH 055/194] fix(agent): reuse protected set for private facade preselection --- src/agent/hosted/executor-runtime-prepare.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 505cafce2a..2b4bb3497d 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -77,10 +77,6 @@ const objectDefineProperty = Object.defineProperty; const objectEntries = Object.entries; const arrayFilter = Array.prototype.filter; const arrayIncludes = Array.prototype.includes; -const arrayMap = Array.prototype.map; -const arrayReduce = Array.prototype.reduce; -const IntrinsicSet = Set; -const setHas = Set.prototype.has; function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { return apply(arrayFilter, values, [predicate]) as T[]; @@ -88,12 +84,6 @@ function filter(values: readonly T[], predicate: (value: T) => boolean): T[] function includes(values: readonly T[], value: T): boolean { return apply(arrayIncludes, values, [value]) as boolean; } -function map(values: readonly T[], callback: (value: T) => U): U[] { - return apply(arrayMap, values, [callback]) as U[]; -} -function reduce(values: readonly T[], callback: (result: U, value: T) => U, initial: U): U { - return apply(arrayReduce, values, [callback, initial]) as U; -} function privateMapGet(map: ReadonlyMap, key: K): V | undefined { return apply(mapGet, map, [key]) as V | undefined; @@ -106,14 +96,14 @@ function selectAllowedHostTools( tools: HostToolSet, allowedNames: readonly string[], ): HostToolSet { - const allowed = new IntrinsicSet(allowedNames); + 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 || !apply(setHas, allowed, [entry[0]])) continue; + if (entry === undefined || !allowed.has(entry[0])) continue; apply(objectDefineProperty, Object, [ selected, entry[0], From b255f93151ca7373f08968aaaa7525a88deeea9b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 14:49:00 +0200 Subject: [PATCH 056/194] fix(agent): preserve dictionary types in reflection helpers --- src/agent/hosted/chat-runtime-tool-assembly.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index a89d09f387..d335914cb2 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -62,11 +62,11 @@ function ownEntries(value: Record): Array<[string, T]> { return apply(objectEntries, Object, [value]) as Array<[string, T]>; } -function ownKeys(value: object): string[] { +function ownKeys(value: Record): string[] { return apply(objectKeys, Object, [value]) as string[]; } -function hasOwn(value: object, key: PropertyKey): boolean { +function hasOwn(value: Record, key: PropertyKey): boolean { return apply(objectHasOwn, Object, [value, key]) as boolean; } From 88f284d9744d598389b6c98a20e07836fc15f64d Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 14:50:15 +0200 Subject: [PATCH 057/194] test(agent): harden private facade reflection regression --- .../executor-runtime-private-facades.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index d3d09064e9..ece774540f 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -18,7 +18,9 @@ const modelId = "veryfront-cloud/openai/gpt-5.4"; it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { const originalEntries = Object.entries; const originalSetHas = Set.prototype.has; + const originalSetAdd = Set.prototype.add; const originalReduce = Array.prototype.reduce; + const originalArrayIterator = Array.prototype[Symbol.iterator]; let hiddenExecutions = 0; let remoteExecutions = 0; const visible = { @@ -69,6 +71,17 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", signal: new AbortController().signal, backend: { load: () => { + Set.prototype.add = function (value: unknown) { + Reflect.apply(originalSetAdd, this, [value]); + if (value === "visible") Reflect.apply(originalSetAdd, this, ["hidden"]); + return this; + }; + Array.prototype[Symbol.iterator] = function () { + const entry = this as unknown[]; + const candidate = entry[1] as { execute?: () => unknown } | undefined; + if (entry[0] === "hidden" && candidate?.execute) candidate.execute(); + return Reflect.apply(originalArrayIterator, this, []); + }; Object.entries = ((value: object) => { const entries = Reflect.apply(originalEntries, Object, [value]); for (let index = 0; index < entries.length; index++) { @@ -139,7 +152,9 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", } finally { Object.entries = originalEntries; Set.prototype.has = originalSetHas; + Set.prototype.add = originalSetAdd; Array.prototype.reduce = originalReduce; + Array.prototype[Symbol.iterator] = originalArrayIterator; await owner.close(); } }); From 9935e66b2402d5727ccbf1ddbf48fe808e15b123 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 15:01:27 +0200 Subject: [PATCH 058/194] fix(agent): harden facade selection boundaries --- .../hosted/executor-runtime-prepare.test.ts | 32 +++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 24 ++++++++++---- .../executor-runtime-private-facades.test.ts | 9 +++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index b029ca53e0..3cbd7d8998 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -592,6 +592,38 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + it("preserves a granted provider-selected local web fetch fallback", async () => { + let visible: string[] = []; + const f = fixture({ + config: { tools: {}, providerTools: ["web_fetch"] }, + grant: { + ...grant, + allowedToolNames: ["web_fetch"], + hostToolFacadeIds: ["local"], + models: new Map([[ + modelId, + { maxOutputTokens: 200, providerToolNames: ["web_fetch"] }, + ]]), + }, + facades: { + hostTools: new Map([["local", { web_fetch: syntheticHostTool() }]]), + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(visible, ["web_fetch"]); + } finally { + await f.owner.close(); + } + }); + for ( const selection of [ { diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 2b4bb3497d..55db1e6b1b 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -273,15 +273,22 @@ export function createExecutorRuntimePreparation(input: Options) { if ( typeof facades.resolveModelRuntime !== "function" || typeof facades.cleanup !== "function" ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - for (const id of effective.hostToolFacadeIds) { + 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 (const id of effective.remoteToolSourceIds) { + 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"); } } - for (const server of definition.mcpServers ?? []) { + 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"); } @@ -348,7 +355,9 @@ export function createExecutorRuntimePreparation(input: Options) { !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId: definition.id }), ), ); - for (const id of grant.hostToolFacadeIds) { + 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) }; @@ -528,13 +537,16 @@ export function createExecutorRuntimePreparation(input: Options) { } remoteToolSources[remoteToolSources.length] = remoteToolSource; } + const facadeAllowedToolNames = [ + ...createPrivateSet([...allowedToolNames, ...providerToolNames]), + ]; const toolAssembly = await prepareFacadedHostedChatRuntimeToolAssembly({ signal: context.signal, taskContext, instructions: options.instructions, - localTools: selectAllowedHostTools(localTools, allowedToolNames), + localTools: selectAllowedHostTools(localTools, facadeAllowedToolNames), sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - hostToolPolicy: { allow: allowedToolNames }, + hostToolPolicy: { allow: facadeAllowedToolNames }, allowedToolNames, deniedToolNames, allowedProviderToolNames: providerToolNames, diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index ece774540f..a18f99993b 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -6,6 +6,7 @@ import { agent } from "#veryfront/agent/factory.ts"; import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; +import type { HostToolSet } from "#veryfront/tool"; const binding = { allocationId: "reflection-allocation", @@ -78,6 +79,9 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", }; Array.prototype[Symbol.iterator] = function () { const entry = this as unknown[]; + if (entry.length === 1 && entry[0] === "local") { + return Reflect.apply(originalArrayIterator, ["ungranted"], []); + } const candidate = entry[1] as { execute?: () => unknown } | undefined; if (entry[0] === "hidden" && candidate?.execute) candidate.execute(); return Reflect.apply(originalArrayIterator, this, []); @@ -126,7 +130,10 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", execution: { kind: "ephemeral", projectId: null }, }, facades: { - hostTools: new Map([["local", { visible, hidden }]]), + hostTools: new Map([ + ["local", { visible, hidden }], + ["ungranted", { visible: hidden }], + ]), remoteToolSources: new Map([["api", remote]]), resolveModelRuntime: () => ({ modelId: "gpt-5.4", From 7ce20db848b31e662bd7aae2ececd9add9d033dd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:01:41 +0200 Subject: [PATCH 059/194] fix(tool): avoid mutable iteration and inherited facade writes --- src/tool/host-tools.test.ts | 13 +++++++++++++ src/tool/host-tools.ts | 11 +++++++---- src/tool/project-scoped-remote-tools.test.ts | 14 ++++++++++++++ src/tool/project-scoped-remote-tools.ts | 4 +++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/tool/host-tools.test.ts b/src/tool/host-tools.test.ts index 2096d0188b..3f495fe425 100644 --- a/src/tool/host-tools.test.ts +++ b/src/tool/host-tools.test.ts @@ -15,6 +15,19 @@ import type { RemoteToolSource, ToolExecutionContext, ToolSet } from "./types.ts const emptyJsonSchema = { type: "object" as const, properties: {} }; describe("tool/host-tools", () => { + it("materializes prototype-named tools as own data properties", async () => { + const tools = createToolsFromHostDefinitions({ + ["__proto__"]: { + description: "Synthetic tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }, + }); + assertEquals(Object.keys(tools), ["__proto__"]); + assertEquals(Object.getPrototypeOf(tools), Object.prototype); + assertEquals(await tools["__proto__"]?.execute({}), { ok: true }); + }); + it("preserves trusted host provenance through materialization", () => { const trustedDefinition = markTrustedHostToolProvenance({ description: "Trusted framework tool", diff --git a/src/tool/host-tools.ts b/src/tool/host-tools.ts index 9d7c384e23..613048fd86 100644 --- a/src/tool/host-tools.ts +++ b/src/tool/host-tools.ts @@ -8,6 +8,7 @@ import { inheritTrustedHostToolProvenance } from "./host-tool-provenance.ts"; const apply = Reflect.apply; const arrayIsArray = Array.isArray; const objectEntries = Object.entries; +const objectDefineProperty = Object.defineProperty; type HostToolExecute = { bivarianceHack: (input: unknown, options?: ToolExecutionContext) => Promise | unknown; @@ -150,10 +151,12 @@ export function createToolsFromHostDefinitions( const toolWithRemoteProvenance = canonicalRemoteToolName ? markRemoteToolProvenance(materializedTool, canonicalRemoteToolName) : materializedTool; - tools[toolName] = inheritTrustedHostToolProvenance( - definition, - toolWithRemoteProvenance, - ); + objectDefineProperty(tools, toolName, { + value: inheritTrustedHostToolProvenance(definition, toolWithRemoteProvenance), + enumerable: true, + configurable: true, + writable: true, + }); } } catch (error) { agentLogger.warn("Skipping host tool: schema conversion failed", { diff --git a/src/tool/project-scoped-remote-tools.test.ts b/src/tool/project-scoped-remote-tools.test.ts index bc48dfaa8c..54cb644b2f 100644 --- a/src/tool/project-scoped-remote-tools.test.ts +++ b/src/tool/project-scoped-remote-tools.test.ts @@ -37,6 +37,20 @@ function toolDefinition(input: { }; } +it("lists indexed source entries without invoking a caller-supplied array iterator", async () => { + const sources: RemoteToolSource[] = [{ + id: "synthetic-source", + listTools: () => Promise.resolve([toolDefinition({ name: "read_file" })]), + executeTool: () => Promise.resolve({ ok: true }), + }]; + Object.defineProperty(sources, Symbol.iterator, { + value: () => { + throw new Error("Source-array iterator must not receive private sources"); + }, + }); + assertEquals(await listProjectScopedRemoteToolNames(sources, { projectId: null }), ["read_file"]); +}); + it("filterProjectScopedRemoteToolDefinitions hides project-bound tools when no active project exists", () => { const tools = [ toolDefinition({ name: "list_projects" }), diff --git a/src/tool/project-scoped-remote-tools.ts b/src/tool/project-scoped-remote-tools.ts index 3ea91eacc0..bf3dd9b244 100644 --- a/src/tool/project-scoped-remote-tools.ts +++ b/src/tool/project-scoped-remote-tools.ts @@ -447,7 +447,9 @@ export async function listProjectScopedRemoteToolNames( const remoteToolNames = new Set(); const sourceContext = withActiveProjectContext(options.context, options.projectId); - for (const source of remoteSources) { + for (let index = 0; index < remoteSources.length; index++) { + const source = remoteSources[index]; + if (source === undefined) continue; const toolDefinitions = filterProjectScopedRemoteToolDefinitions( await source.listTools(sourceContext), options.projectId, From a23fe95a1baabd53b4e4229a7f717088d547713a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 15:09:08 +0200 Subject: [PATCH 060/194] fix(agent): avoid inherited facade metadata hooks --- .../hosted/chat-runtime-tool-assembly.ts | 29 ++++++++++++++++--- .../executor-runtime-private-facades.test.ts | 29 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index d335914cb2..1c826333f4 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -51,10 +51,10 @@ import { compareStrings } from "#veryfront/utils/compare.ts"; const apply = Reflect.apply; const arrayFilter = Array.prototype.filter; const arrayIncludes = Array.prototype.includes; -const arrayMap = Array.prototype.map; const arraySort = Array.prototype.sort; const objectDefineProperty = Object.defineProperty; const objectEntries = Object.entries; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const objectHasOwn = Object.hasOwn; const objectKeys = Object.keys; @@ -81,7 +81,20 @@ function mapValues( values: readonly T[], callback: (value: T, index: number, array: readonly T[]) => U, ): U[] { - return apply(arrayMap, values, [callback]) as U[]; + const mapped: U[] = []; + for (let index = 0; index < values.length; index++) { + apply(objectDefineProperty, Object, [ + mapped, + index, + { + value: callback(values[index]!, index, values), + enumerable: true, + configurable: true, + writable: true, + }, + ]); + } + return mapped; } function sortValues(values: T[], compare: (left: T, right: T) => number): T[] { @@ -106,6 +119,14 @@ function recordFromEntries(entries: readonly (readonly [string, T])[]): Recor return result; } +function ownDataValue(value: object, key: PropertyKey): unknown { + try { + return apply(objectGetOwnPropertyDescriptor, Object, [value, key])?.value; + } catch { + return undefined; + } +} + /** Context for hosted chat runtime tool assembly. */ export type HostedChatRuntimeToolAssemblyContext = DefaultResearchArtifactContext & { authToken: string; @@ -371,8 +392,8 @@ function resolveOwnerScopedToolName(input: { const registeredName = pair[0]; const tool = pair[1]; if ( - tool.ownerAgentId === input.agentId && - tool.shortName === input.toolName + ownDataValue(tool, "ownerAgentId") === input.agentId && + ownDataValue(tool, "shortName") === input.toolName ) { return registeredName; } diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index a18f99993b..2ac528bce8 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -22,6 +22,14 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", const originalSetAdd = Set.prototype.add; const originalReduce = Array.prototype.reduce; const originalArrayIterator = Array.prototype[Symbol.iterator]; + const originalOwnerAgentId = Object.getOwnPropertyDescriptor( + Object.prototype, + "ownerAgentId", + ); + const originalArrayConstructor = Object.getOwnPropertyDescriptor( + Array.prototype, + "constructor", + )!; let hiddenExecutions = 0; let remoteExecutions = 0; const visible = { @@ -72,6 +80,21 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", signal: new AbortController().signal, backend: { load: () => { + Object.defineProperty(Object.prototype, "ownerAgentId", { + configurable: true, + get() { + if (this === hidden) hidden.execute(); + return undefined; + }, + }); + Object.defineProperty(Array.prototype, "constructor", { + configurable: true, + get() { + const values = this as unknown[]; + if (values[0] === remote) void remote.executeTool(); + return Array; + }, + }); Set.prototype.add = function (value: unknown) { Reflect.apply(originalSetAdd, this, [value]); if (value === "visible") Reflect.apply(originalSetAdd, this, ["hidden"]); @@ -162,6 +185,12 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", Set.prototype.add = originalSetAdd; Array.prototype.reduce = originalReduce; Array.prototype[Symbol.iterator] = originalArrayIterator; + if (originalOwnerAgentId) { + Object.defineProperty(Object.prototype, "ownerAgentId", originalOwnerAgentId); + } else { + delete (Object.prototype as { ownerAgentId?: unknown }).ownerAgentId; + } + Object.defineProperty(Array.prototype, "constructor", originalArrayConstructor); await owner.close(); } }); From 87ccccc019f1612949c4342ae07c8ed0db70f929 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 15:10:23 +0200 Subject: [PATCH 061/194] test(agent): pin private facade runtime output type --- .../integration/agent/executor-runtime-private-facades.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 2ac528bce8..36291cc388 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -44,7 +44,7 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", return { ok: true }; }, }; - const coder = agent({ + const coder = agent({ id: "coder", system: "Synthetic instructions", model: modelId, From 8e7f045b9633744c931004f3ef238d3ad8336142 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:11:20 +0200 Subject: [PATCH 062/194] fix(agent): type reflected host tool metadata precisely --- src/agent/hosted/chat-runtime-tool-assembly.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 1c826333f4..ca79df1f95 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -119,7 +119,7 @@ function recordFromEntries(entries: readonly (readonly [string, T])[]): Recor return result; } -function ownDataValue(value: object, key: PropertyKey): unknown { +function ownDataValue(value: HostToolSet[string], key: PropertyKey): unknown { try { return apply(objectGetOwnPropertyDescriptor, Object, [value, key])?.value; } catch { From 7119dbd43ad12e5334495ebd9da6e097f9f45a6f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:21:20 +0200 Subject: [PATCH 063/194] fix(agent): protect stream dispatch and retained promise chains --- src/agent/hosted/executor-agent-bridge.ts | 10 ++++-- src/agent/hosted/executor-discovery.ts | 33 ++++++++++++++------ src/agent/hosted/executor-runtime-prepare.ts | 6 ++-- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 81cbb89ed0..a9f674bfc1 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -21,6 +21,9 @@ import { } from "./executor-agent-schema.ts"; const textEncoder = new TextEncoder(); +const MapConstructor = Map; +const mapSet = Map.prototype.set; +const apply = Reflect.apply; async function startExecutorRuntimeStream( start: () => Promise>, @@ -50,7 +53,7 @@ export function createExecutorAgentOperations(options: { options.preparedRuntimeHandle, ); let started = false; - return new Map([["agent.stream", { + const streamOperation: ExecutorOperation = { mode: "stream", async *handle(value, context) { let phase: "setup" | "stream" = "setup"; @@ -100,7 +103,10 @@ export function createExecutorAgentOperations(options: { context.signal.throwIfAborted(); yield failure ?? { type: "complete" }; }, - }]]); + }; + const operations = new MapConstructor(); + apply(mapSet, operations, ["agent.stream", streamOperation]); + return operations; } function parseFrame(value: unknown) { diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 51e35c2561..c3f1027db7 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -26,6 +26,17 @@ import { parseDiscoveryData, } from "./executor-discovery-schema.ts"; +const apply = Reflect.apply; +const promiseThen = Promise.prototype.then; + +function chain( + promise: Promise, + fulfilled: (value: T) => U | PromiseLike, + rejected?: (reason: unknown) => U | PromiseLike, +): Promise { + return apply(promiseThen, promise, [fulfilled, rejected]) as Promise; +} + export interface ExecutorDiscoveryBackend { load(signal: AbortSignal): Promise; /** Own partial setup even when load throws before returning a runtime. */ @@ -83,7 +94,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut const projectDir = input.projectDir; const lifetime = new AbortController(); const settled = Promise.withResolvers(); - void settled.promise.catch(() => {}); + void chain(settled.promise, () => {}, () => {}); let tail: Promise = Promise.resolve(); let backend = input.backend; let loadStarted = false; @@ -102,11 +113,13 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut function close(): Promise { if (closing) return closing; // Memoize before synchronous abort listeners can reenter close(). - closing = tail.then(async () => { + closing = chain(tail, async () => { try { // Re-read after each batch: retained startup may reserve producer work // after cancellation, before its own promise settles. - while (runtimeTasks.size > 0) await Promise.all(runtimeTasks); + while (runtimeTasks.size > 0) { + for (const task of runtimeTasks) await task; + } cleanupStarted = true; if (loadStarted) await backend?.cleanup(runtime); } catch { @@ -116,13 +129,13 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut definitions.clear(); } }); - void closing.then(settled.resolve, settled.reject); + void chain(closing, settled.resolve, settled.reject); input.signal.removeEventListener("abort", onAbort); lifetime.abort(); return closing; } const onAbort = () => { - void close().catch(() => {}); + void chain(close(), () => {}, () => {}); }; input.signal.addEventListener("abort", onAbort, { once: true }); if (input.signal.aborted) onAbort(); @@ -223,10 +236,10 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut ) return { ok: false, code: "EXECUTOR_DISCOVERY_BINDING_MISMATCH" }; if (lifetime.signal.aborted) return { ok: false, code: "EXECUTOR_DISCOVERY_CLOSED" }; const onCancel = () => { - void close().catch(() => {}); + void chain(close(), () => {}, () => {}); }; context.signal.addEventListener("abort", onCancel, { once: true }); - const work = tail.then(async () => { + const work = chain(tail, async () => { if (context.signal.aborted || Date.now() >= context.deadline) { onCancel(); throw new ExecutorDiscoveryError("ABORTED"); @@ -240,7 +253,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } return value; }); - tail = work.then(() => {}, () => {}); + tail = chain(work, () => {}, () => {}); if (context.signal.aborted) onCancel(); try { return await work; @@ -309,9 +322,9 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut close, retainRuntimeTask(task) { if (cleanupStarted) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLOSED"); - const retained = task.then(() => {}, () => {}); + const retained = chain(task, () => {}, () => {}); runtimeTasks.add(retained); - void retained.then(() => runtimeTasks.delete(retained)); + void chain(retained, () => runtimeTasks.delete(retained)); }, getRuntime() { assertActive(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 55db1e6b1b..588c79b933 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -326,7 +326,7 @@ export function createExecutorRuntimePreparation(input: Options) { if (!grant || grant.agentId !== request.agentId || !sameBinding(binding, context.binding)) { refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); } - const operation = input.discovery.operations.get("agent.describe"); + const operation = privateMapGet(input.discovery.operations, "agent.describe"); if (operation?.mode !== "unary") refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); const described = getExecutorAgentDescribeResultSchema().parse( await operation.handle({ agentId: request.agentId }, context), @@ -686,7 +686,9 @@ export function createExecutorRuntimePreparation(input: Options) { async *handle(value, context) { assertActive(); if (!sameBinding(binding, context.binding)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - const operation = preparedOperations?.get("agent.stream"); + const operation = preparedOperations === undefined + ? undefined + : privateMapGet(preparedOperations, "agent.stream"); if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); yield* operation.handle(value, { ...context, From a81010c57e09268739136c1f3ab4e7a78815ace6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:25:14 +0200 Subject: [PATCH 064/194] fix(agent): keep facade filtering outside array construction hooks --- src/agent/hosted/chat-runtime-tool-assembly.ts | 15 +++++++++++++-- .../executor-runtime-private-facades.test.ts | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index ca79df1f95..65e6ea01a8 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -49,7 +49,6 @@ import { TOOL_SEARCH_TOOL_NAME } from "../runtime/tool-exposure.ts"; import { compareStrings } from "#veryfront/utils/compare.ts"; const apply = Reflect.apply; -const arrayFilter = Array.prototype.filter; const arrayIncludes = Array.prototype.includes; const arraySort = Array.prototype.sort; const objectDefineProperty = Object.defineProperty; @@ -74,7 +73,19 @@ function filterValues( values: readonly T[], predicate: (value: T, index: number, array: readonly T[]) => unknown, ): T[] { - return apply(arrayFilter, values, [predicate]) as T[]; + const filtered: T[] = []; + for (let index = 0; index < values.length; index++) { + if (!objectHasOwn(values, index)) continue; + const value = values[index]!; + if (!predicate(value, index, values)) continue; + objectDefineProperty(filtered, filtered.length, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return filtered; } function mapValues( diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 36291cc388..b4cde48628 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -32,6 +32,7 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", )!; let hiddenExecutions = 0; let remoteExecutions = 0; + let exposedFacadeValues = 0; const visible = { description: "Synthetic tool", inputSchema: defineSchema((v) => v.object({}))(), @@ -92,6 +93,10 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", get() { const values = this as unknown[]; if (values[0] === remote) void remote.executeTool(); + for (let index = 0; index < values.length; index++) { + const entry = values[index] as readonly unknown[] | undefined; + if (Array.isArray(entry) && entry[1] === visible) exposedFacadeValues++; + } return Array; }, }); @@ -179,6 +184,7 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); assertEquals(hiddenExecutions, 0); assertEquals(remoteExecutions, 0); + assertEquals(exposedFacadeValues, 0); } finally { Object.entries = originalEntries; Set.prototype.has = originalSetHas; From b4e03b196ae7ba711af6821c2da8f1ee66637a02 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 15:28:57 +0200 Subject: [PATCH 065/194] fix(agent): merge facade grants without mutable iterators --- src/agent/hosted/executor-runtime-prepare.ts | 13 ++++++++++--- .../agent/executor-runtime-private-facades.test.ts | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 588c79b933..68d001a2c5 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -537,9 +537,16 @@ export function createExecutorRuntimePreparation(input: Options) { } remoteToolSources[remoteToolSources.length] = remoteToolSource; } - const facadeAllowedToolNames = [ - ...createPrivateSet([...allowedToolNames, ...providerToolNames]), - ]; + 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 toolAssembly = await prepareFacadedHostedChatRuntimeToolAssembly({ signal: context.signal, taskContext, diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index b4cde48628..fea4e0d0fa 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -110,6 +110,9 @@ it("keeps ungranted private facades out of project-controlled reflection hooks", if (entry.length === 1 && entry[0] === "local") { return Reflect.apply(originalArrayIterator, ["ungranted"], []); } + if (entry.length === 1 && entry[0] === "visible") { + entry[1] = "hidden"; + } const candidate = entry[1] as { execute?: () => unknown } | undefined; if (entry[0] === "hidden" && candidate?.execute) candidate.execute(); return Reflect.apply(originalArrayIterator, this, []); From d643e191183e7fbe3b07683710135cdbe0dc8cc6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:29:38 +0200 Subject: [PATCH 066/194] test(agent): align review tests and internal import aliases --- .../hosted/chat-runtime-tool-assembly.test.ts | 2 +- .../hosted/chat-runtime-tool-assembly.ts | 2 +- src/tool/project-scoped-remote-tools.test.ts | 28 +- .../executor-runtime-private-facades.test.ts | 364 +++++++++--------- 4 files changed, 201 insertions(+), 195 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index 889bff1da3..ea55e9c0d7 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -21,7 +21,7 @@ import { prepareConfigDerivedHostedChatRuntimeToolAssembly, prepareFacadedHostedChatRuntimeToolAssembly, prepareHostedChatRuntimeToolAssembly, -} from "./chat-runtime-tool-assembly.ts"; +} from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; it("facaded assembly preserves project tool normalization and mutation callbacks without private transport config", async () => { const calls: Array> = []; diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 65e6ea01a8..306c570fcf 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -26,7 +26,7 @@ import { type HostedProjectRemoteToolSourcePrepareToolInput, type HostedProjectRemoteToolSourceProjectSwitchHandler, type HostedProjectRemoteToolSourceRetryPolicy, -} from "./project-remote-tool-source.ts"; +} from "#veryfront/agent/hosted/project-remote-tool-source.ts"; import { wrapRemoteToolSourceWithMcpPolicy } from "#veryfront/agent/mcp-tool-policy.ts"; import { type RuntimeClientProfile } from "../runtime/client-profile.ts"; import { selectProviderCompatibleToolNames } from "../runtime/provider-tool-compat.ts"; diff --git a/src/tool/project-scoped-remote-tools.test.ts b/src/tool/project-scoped-remote-tools.test.ts index 54cb644b2f..1d7d6fe084 100644 --- a/src/tool/project-scoped-remote-tools.test.ts +++ b/src/tool/project-scoped-remote-tools.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { it } from "#veryfront/testing/bdd.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { assertEquals, assertRejects, @@ -37,18 +37,22 @@ function toolDefinition(input: { }; } -it("lists indexed source entries without invoking a caller-supplied array iterator", async () => { - const sources: RemoteToolSource[] = [{ - id: "synthetic-source", - listTools: () => Promise.resolve([toolDefinition({ name: "read_file" })]), - executeTool: () => Promise.resolve({ ok: true }), - }]; - Object.defineProperty(sources, Symbol.iterator, { - value: () => { - throw new Error("Source-array iterator must not receive private sources"); - }, +describe("private remote source listing", () => { + it("lists indexed source entries without invoking a caller-supplied array iterator", async () => { + const sources: RemoteToolSource[] = [{ + id: "synthetic-source", + listTools: () => Promise.resolve([toolDefinition({ name: "read_file" })]), + executeTool: () => Promise.resolve({ ok: true }), + }]; + Object.defineProperty(sources, Symbol.iterator, { + value: () => { + throw new Error("Source-array iterator must not receive private sources"); + }, + }); + assertEquals(await listProjectScopedRemoteToolNames(sources, { projectId: null }), [ + "read_file", + ]); }); - assertEquals(await listProjectScopedRemoteToolNames(sources, { projectId: null }), ["read_file"]); }); it("filterProjectScopedRemoteToolDefinitions hides project-bound tools when no active project exists", () => { diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index fea4e0d0fa..62b84547d9 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; -import { it } from "#veryfront/testing/bdd.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import { agent } from "#veryfront/agent/factory.ts"; import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; @@ -16,190 +16,192 @@ const binding = { const source = { type: "release", releaseId: "synthetic-release" } as const; const modelId = "veryfront-cloud/openai/gpt-5.4"; -it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { - const originalEntries = Object.entries; - const originalSetHas = Set.prototype.has; - const originalSetAdd = Set.prototype.add; - const originalReduce = Array.prototype.reduce; - const originalArrayIterator = Array.prototype[Symbol.iterator]; - const originalOwnerAgentId = Object.getOwnPropertyDescriptor( - Object.prototype, - "ownerAgentId", - ); - const originalArrayConstructor = Object.getOwnPropertyDescriptor( - Array.prototype, - "constructor", - )!; - let hiddenExecutions = 0; - let remoteExecutions = 0; - let exposedFacadeValues = 0; - const visible = { - description: "Synthetic tool", - inputSchema: defineSchema((v) => v.object({}))(), - execute: () => ({ ok: true }), - }; - const hidden = { - ...visible, - execute: () => { - hiddenExecutions++; - return { ok: true }; - }, - }; - const coder = agent({ - id: "coder", - system: "Synthetic instructions", - model: modelId, - tools: true, - mcpServers: [{ kind: "veryfront-api", id: "api" }], - }); - const remote = { - id: "api", - listTools: () => Promise.resolve([]), - executeTool: () => { - remoteExecutions++; - return Promise.resolve({ ok: true }); - }, - }; - const runtime: ProjectAgentRuntimeDiscovery = { - agents: new Map([[coder.id, coder]]), - tools: new Map(), - 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, - projectDir: "/synthetic-project", - signal: new AbortController().signal, - backend: { - load: () => { - Object.defineProperty(Object.prototype, "ownerAgentId", { - configurable: true, - get() { - if (this === hidden) hidden.execute(); - return undefined; - }, - }); - Object.defineProperty(Array.prototype, "constructor", { - configurable: true, - get() { - const values = this as unknown[]; - if (values[0] === remote) void remote.executeTool(); - for (let index = 0; index < values.length; index++) { - const entry = values[index] as readonly unknown[] | undefined; - if (Array.isArray(entry) && entry[1] === visible) exposedFacadeValues++; +describe("private executor facades", () => { + it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { + const originalEntries = Object.entries; + const originalSetHas = Set.prototype.has; + const originalSetAdd = Set.prototype.add; + const originalReduce = Array.prototype.reduce; + const originalArrayIterator = Array.prototype[Symbol.iterator]; + const originalOwnerAgentId = Object.getOwnPropertyDescriptor( + Object.prototype, + "ownerAgentId", + ); + const originalArrayConstructor = Object.getOwnPropertyDescriptor( + Array.prototype, + "constructor", + )!; + let hiddenExecutions = 0; + let remoteExecutions = 0; + let exposedFacadeValues = 0; + const visible = { + description: "Synthetic tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }; + const hidden = { + ...visible, + execute: () => { + hiddenExecutions++; + return { ok: true }; + }, + }; + const coder = agent({ + id: "coder", + system: "Synthetic instructions", + model: modelId, + tools: true, + mcpServers: [{ kind: "veryfront-api", id: "api" }], + }); + const remote = { + id: "api", + listTools: () => Promise.resolve([]), + executeTool: () => { + remoteExecutions++; + return Promise.resolve({ ok: true }); + }, + }; + const runtime: ProjectAgentRuntimeDiscovery = { + agents: new Map([[coder.id, coder]]), + tools: new Map(), + 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, + projectDir: "/synthetic-project", + signal: new AbortController().signal, + backend: { + load: () => { + Object.defineProperty(Object.prototype, "ownerAgentId", { + configurable: true, + get() { + if (this === hidden) hidden.execute(); + return undefined; + }, + }); + Object.defineProperty(Array.prototype, "constructor", { + configurable: true, + get() { + const values = this as unknown[]; + if (values[0] === remote) void remote.executeTool(); + for (let index = 0; index < values.length; index++) { + const entry = values[index] as readonly unknown[] | undefined; + if (Array.isArray(entry) && entry[1] === visible) exposedFacadeValues++; + } + return Array; + }, + }); + Set.prototype.add = function (value: unknown) { + Reflect.apply(originalSetAdd, this, [value]); + if (value === "visible") Reflect.apply(originalSetAdd, this, ["hidden"]); + return this; + }; + Array.prototype[Symbol.iterator] = function () { + const entry = this as unknown[]; + if (entry.length === 1 && entry[0] === "local") { + return Reflect.apply(originalArrayIterator, ["ungranted"], []); + } + if (entry.length === 1 && entry[0] === "visible") { + entry[1] = "hidden"; + } + const candidate = entry[1] as { execute?: () => unknown } | undefined; + if (entry[0] === "hidden" && candidate?.execute) candidate.execute(); + return Reflect.apply(originalArrayIterator, this, []); + }; + Object.entries = ((value: object) => { + const entries = Reflect.apply(originalEntries, Object, [value]); + for (let index = 0; index < entries.length; index++) { + if (entries[index]?.[1] === hidden) hidden.execute(); } - return Array; - }, - }); - Set.prototype.add = function (value: unknown) { - Reflect.apply(originalSetAdd, this, [value]); - if (value === "visible") Reflect.apply(originalSetAdd, this, ["hidden"]); - return this; - }; - Array.prototype[Symbol.iterator] = function () { - const entry = this as unknown[]; - if (entry.length === 1 && entry[0] === "local") { - return Reflect.apply(originalArrayIterator, ["ungranted"], []); - } - if (entry.length === 1 && entry[0] === "visible") { - entry[1] = "hidden"; - } - const candidate = entry[1] as { execute?: () => unknown } | undefined; - if (entry[0] === "hidden" && candidate?.execute) candidate.execute(); - return Reflect.apply(originalArrayIterator, this, []); - }; - Object.entries = ((value: object) => { - const entries = Reflect.apply(originalEntries, Object, [value]); - for (let index = 0; index < entries.length; index++) { - if (entries[index]?.[1] === hidden) hidden.execute(); - } - return entries; - }) as typeof Object.entries; - Set.prototype.has = function (value: unknown) { - if ( - value === "hidden" && - Reflect.apply(originalSetHas, this, ["visible"]) === true + return entries; + }) as typeof Object.entries; + Set.prototype.has = function (value: unknown) { + if ( + value === "hidden" && + Reflect.apply(originalSetHas, this, ["visible"]) === true + ) { + return true; + } + return Reflect.apply(originalSetHas, this, [value]); + }; + Array.prototype.reduce = (function ( + this: unknown[], + callback: (...args: unknown[]) => unknown, + ...initial: unknown[] ) { - return true; - } - return Reflect.apply(originalSetHas, this, [value]); - }; - Array.prototype.reduce = (function ( - this: unknown[], - callback: (...args: unknown[]) => unknown, - ...initial: unknown[] - ) { - if (initial[0] === remote) void remote.executeTool(); - return Reflect.apply(originalReduce, this, [callback, ...initial]); - }) as typeof Array.prototype.reduce; - return Promise.resolve(runtime); + if (initial[0] === remote) void remote.executeTool(); + return Reflect.apply(originalReduce, this, [callback, ...initial]); + }) as typeof Array.prototype.reduce; + return Promise.resolve(runtime); + }, + cleanup: () => Promise.resolve(), }, - cleanup: () => Promise.resolve(), - }, - }); - const owner = createExecutorRuntimePreparation({ - binding, - source, - discovery, - grant: { - agentId: "coder", - defaultModelId: modelId, - maxSteps: 5, - models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), - allowedToolNames: ["visible"], - hostToolFacadeIds: ["local"], - remoteToolSourceIds: ["api"], - execution: { kind: "ephemeral", projectId: null }, - }, - facades: { - hostTools: new Map([ - ["local", { visible, hidden }], - ["ungranted", { visible: hidden }], - ]), - remoteToolSources: new Map([["api", remote]]), - resolveModelRuntime: () => ({ - modelId: "gpt-5.4", - provider: "openai", - specificationVersion: "v3", - doGenerate: () => Promise.reject(new Error("Unexpected generate call")), - doStream: () => Promise.reject(new Error("Unexpected stream call")), - }), - cleanup: () => Promise.resolve(), - }, - }); - try { - const operation = owner.operations.get("runtime.prepare"); - assert(operation?.mode === "unary"); - const result = await operation.handle({ agentId: "coder" }, { + }); + const owner = createExecutorRuntimePreparation({ binding, - signal: new AbortController().signal, - deadline: Date.now() + 30_000, + source, + discovery, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: ["visible"], + hostToolFacadeIds: ["local"], + remoteToolSourceIds: ["api"], + execution: { kind: "ephemeral", projectId: null }, + }, + facades: { + hostTools: new Map([ + ["local", { visible, hidden }], + ["ungranted", { visible: hidden }], + ]), + remoteToolSources: new Map([["api", remote]]), + resolveModelRuntime: () => ({ + modelId: "gpt-5.4", + provider: "openai", + specificationVersion: "v3", + doGenerate: () => Promise.reject(new Error("Unexpected generate call")), + doStream: () => Promise.reject(new Error("Unexpected stream call")), + }), + cleanup: () => Promise.resolve(), + }, }); - assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); - assertEquals(hiddenExecutions, 0); - assertEquals(remoteExecutions, 0); - assertEquals(exposedFacadeValues, 0); - } finally { - Object.entries = originalEntries; - Set.prototype.has = originalSetHas; - Set.prototype.add = originalSetAdd; - Array.prototype.reduce = originalReduce; - Array.prototype[Symbol.iterator] = originalArrayIterator; - if (originalOwnerAgentId) { - Object.defineProperty(Object.prototype, "ownerAgentId", originalOwnerAgentId); - } else { - delete (Object.prototype as { ownerAgentId?: unknown }).ownerAgentId; + try { + const operation = owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + const result = await operation.handle({ agentId: "coder" }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }); + assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); + assertEquals(hiddenExecutions, 0); + assertEquals(remoteExecutions, 0); + assertEquals(exposedFacadeValues, 0); + } finally { + Object.entries = originalEntries; + Set.prototype.has = originalSetHas; + Set.prototype.add = originalSetAdd; + Array.prototype.reduce = originalReduce; + Array.prototype[Symbol.iterator] = originalArrayIterator; + if (originalOwnerAgentId) { + Object.defineProperty(Object.prototype, "ownerAgentId", originalOwnerAgentId); + } else { + delete (Object.prototype as { ownerAgentId?: unknown }).ownerAgentId; + } + Object.defineProperty(Array.prototype, "constructor", originalArrayConstructor); + await owner.close(); } - Object.defineProperty(Array.prototype, "constructor", originalArrayConstructor); - await owner.close(); - } + }); }); From f1582cbaf27232d8648013b3d019009f5ffaff0c Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 15:38:45 +0200 Subject: [PATCH 067/194] fix(agent): filter facade arrays without construction hooks --- src/agent/hosted/executor-runtime-prepare.ts | 24 ++++++++++++++++--- .../executor-runtime-private-facades.test.ts | 15 ++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 68d001a2c5..6944840047 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -75,11 +75,20 @@ const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; const objectDefineProperty = Object.defineProperty; const objectEntries = Object.entries; -const arrayFilter = Array.prototype.filter; const arrayIncludes = Array.prototype.includes; function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { - return apply(arrayFilter, values, [predicate]) as T[]; + const filtered: T[] = []; + for (let index = 0; index < values.length; index++) { + const value = values[index] as T; + if (!predicate(value)) continue; + apply(objectDefineProperty, Object, [ + 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; @@ -535,7 +544,16 @@ export function createExecutorRuntimePreparation(input: Options) { ); } } - remoteToolSources[remoteToolSources.length] = remoteToolSource; + apply(objectDefineProperty, Object, [ + remoteToolSources, + remoteToolSources.length, + { + value: remoteToolSource, + enumerable: true, + configurable: true, + writable: true, + }, + ]); } const facadeAllowedToolSet = createPrivateSet(); for (let index = 0; index < allowedToolNames.length; index++) { diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 62b84547d9..c1141d6765 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -31,6 +31,7 @@ describe("private executor facades", () => { Array.prototype, "constructor", )!; + const originalArrayZero = Object.getOwnPropertyDescriptor(Array.prototype, "0"); let hiddenExecutions = 0; let remoteExecutions = 0; let exposedFacadeValues = 0; @@ -101,6 +102,18 @@ describe("private executor facades", () => { return Array; }, }); + Object.defineProperty(Array.prototype, "0", { + configurable: true, + set(value) { + Object.defineProperty(this, "0", { + value, + enumerable: true, + configurable: true, + writable: true, + }); + if (value === remote) void remote.executeTool(); + }, + }); Set.prototype.add = function (value: unknown) { Reflect.apply(originalSetAdd, this, [value]); if (value === "visible") Reflect.apply(originalSetAdd, this, ["hidden"]); @@ -201,6 +214,8 @@ describe("private executor facades", () => { delete (Object.prototype as { ownerAgentId?: unknown }).ownerAgentId; } Object.defineProperty(Array.prototype, "constructor", originalArrayConstructor); + if (originalArrayZero) Object.defineProperty(Array.prototype, "0", originalArrayZero); + else delete (Array.prototype as unknown as Record)["0"]; await owner.close(); } }); From da81076666784fe227a6ba9070d6b46f56c63b40 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:44:55 +0200 Subject: [PATCH 068/194] fix(agent): capture cleanup chains and private error reflection --- src/agent/hosted/executor-agent-schema.ts | 4 +++- src/agent/hosted/executor-discovery.ts | 12 +---------- src/agent/hosted/executor-runtime-prepare.ts | 22 ++++++++++++-------- src/security/private-promise.ts | 18 ++++++++++++++++ 4 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 src/security/private-promise.ts diff --git a/src/agent/hosted/executor-agent-schema.ts b/src/agent/hosted/executor-agent-schema.ts index 783b1730b5..c24209afaa 100644 --- a/src/agent/hosted/executor-agent-schema.ts +++ b/src/agent/hosted/executor-agent-schema.ts @@ -5,6 +5,8 @@ import { parseProviderError } from "#veryfront/chat/provider-errors.ts"; import { defineError, snapshotVeryfrontError, VeryfrontError } from "#veryfront/errors/types.ts"; import { EXECUTOR_MAX_FRAME_BYTES } from "../executor/protocol.ts"; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + // Reserve the complete worst-case protocol envelope: two 128-character binding // strings can each require six JSON bytes per character, plus numeric identities, // request metadata, and the four-byte frame prefix. This is a current wire limit, @@ -84,7 +86,7 @@ export class ExecutorAgentError extends VeryfrontError { export function executorAgentFailureCode(error: unknown, fallback: FailureCode): FailureCode { if (error instanceof ExecutorAgentError) return error.code; if (error !== null && typeof error === "object") { - const descriptor = Object.getOwnPropertyDescriptor(error, "code"); + const descriptor = objectGetOwnPropertyDescriptor(error, "code"); const explicit = getExecutorAgentFailureCodeSchema().safeParse(descriptor?.value); if (explicit.success) return explicit.data; } diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index c3f1027db7..20171a057c 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -1,5 +1,6 @@ import { isAbsolute, join, relative, sep } from "node:path"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { chainPrivatePromise as chain } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ProjectAgentRuntimeAgentSource, @@ -26,17 +27,6 @@ import { parseDiscoveryData, } from "./executor-discovery-schema.ts"; -const apply = Reflect.apply; -const promiseThen = Promise.prototype.then; - -function chain( - promise: Promise, - fulfilled: (value: T) => U | PromiseLike, - rejected?: (reason: unknown) => U | PromiseLike, -): Promise { - return apply(promiseThen, promise, [fulfilled, rejected]) as Promise; -} - export interface ExecutorDiscoveryBackend { load(signal: AbortSignal): Promise; /** Own partial setup even when load throws before returning a runtime. */ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 6944840047..7c14415311 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -1,4 +1,8 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { + chainPrivatePromise as chain, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { VERYFRONT_CLOUD_MODEL_PREFIX } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; @@ -230,17 +234,17 @@ export function createExecutorRuntimePreparation(input: Options) { let producerCompletion: Promise | undefined; let streamSignal = lifetime.signal; const settled = Promise.withResolvers(); - void settled.promise.catch(() => {}); + void chain(settled.promise, () => {}, () => {}); const assertActive = () => { if (lifetime.signal.aborted || input.discovery.signal.aborted) { refuse("EXECUTOR_RUNTIME_CLOSED"); } }; const release = () => { - cleanup ??= Promise.resolve().then(async () => { + cleanup ??= chain(resolvePrivatePromise(), async () => { // Startup can still reserve producer work. Join both before releasing // facades, without joining the stream handler that calls this cleanup. - await startup?.catch(() => {}); + if (startup) await chain(startup, () => {}, () => {}); await producerCompletion; if (resourcesStarted) await facades.cleanup(); }); @@ -248,8 +252,8 @@ export function createExecutorRuntimePreparation(input: Options) { }; function close(): Promise { if (closing) return closing; - closing = Promise.resolve().then(async () => { - await preparation?.catch(() => {}); + closing = chain(resolvePrivatePromise(), async () => { + if (preparation) await chain(preparation, () => {}, () => {}); let failed = false; try { await release(); @@ -264,13 +268,13 @@ export function createExecutorRuntimePreparation(input: Options) { preparedOperations = undefined; if (failed) refuse("EXECUTOR_RUNTIME_CLEANUP_FAILED"); }); - void closing.then(settled.resolve, settled.reject); + void chain(closing, settled.resolve, settled.reject); lifetime.abort(); input.discovery.signal.removeEventListener("abort", onDiscoveryAbort); return closing; } const onDiscoveryAbort = () => { - void close().catch(() => {}); + void chain(close(), () => {}, () => {}); }; input.discovery.signal.addEventListener("abort", onDiscoveryAbort, { once: true }); if (input.discovery.signal.aborted) onDiscoveryAbort(); @@ -629,7 +633,7 @@ export function createExecutorRuntimePreparation(input: Options) { preparedRuntimeHandle, startStream: (streamInput) => { streamSignal = streamInput.abortSignal; - startup = Promise.resolve().then(() => { + startup = chain(resolvePrivatePromise(), () => { assertActive(); return createHostedChatRuntimeDataStream({ runtimeAgent, @@ -683,7 +687,7 @@ export function createExecutorRuntimePreparation(input: Options) { ); executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); const cancel = () => { - void close().catch(() => {}); + void chain(close(), () => {}, () => {}); }; context.signal.addEventListener("abort", cancel, { once: true }); preparation = prepare(request, { diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts new file mode 100644 index 0000000000..d5573f94e8 --- /dev/null +++ b/src/security/private-promise.ts @@ -0,0 +1,18 @@ +const PromiseConstructor = Promise; +const apply = Reflect.apply; +const promiseThen = Promise.prototype.then; +const promiseResolve = Promise.resolve; + +/** Chain owned lifecycle work without consulting mutable Promise methods. */ +export function chainPrivatePromise( + promise: Promise, + fulfilled: (value: T) => U | PromiseLike, + rejected?: (reason: unknown) => U | PromiseLike, +): Promise { + return apply(promiseThen, promise, [fulfilled, rejected]) as Promise; +} + +/** Create the initial settled promise for an owned lifecycle chain. */ +export function resolvePrivatePromise(): Promise { + return apply(promiseResolve, PromiseConstructor, []) as Promise; +} From 7d03c1c7e63b93f14bcaf141d8799745627b184b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:48:07 +0200 Subject: [PATCH 069/194] fix(agent): settle private lifecycle work through intrinsic await --- src/security/private-promise.ts | 14 ++- ...executor-runtime-private-lifecycle.test.ts | 119 ++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 tests/integration/agent/executor-runtime-private-lifecycle.test.ts diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index d5573f94e8..05a1f1e2dd 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -1,15 +1,21 @@ const PromiseConstructor = Promise; const apply = Reflect.apply; -const promiseThen = Promise.prototype.then; const promiseResolve = Promise.resolve; -/** Chain owned lifecycle work without consulting mutable Promise methods. */ -export function chainPrivatePromise( +/** Await owned native promises without dispatching through replaced promise methods. */ +export async function chainPrivatePromise( promise: Promise, fulfilled: (value: T) => U | PromiseLike, rejected?: (reason: unknown) => U | PromiseLike, ): Promise { - return apply(promiseThen, promise, [fulfilled, rejected]) as Promise; + let value: T; + try { + value = await promise; + } catch (error) { + if (rejected) return await rejected(error); + throw error; + } + return await fulfilled(value); } /** Create the initial settled promise for an owned lifecycle chain. */ diff --git a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts new file mode 100644 index 0000000000..fd8a080c83 --- /dev/null +++ b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts @@ -0,0 +1,119 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { agent } from "#veryfront/agent/factory.ts"; +import { executorAgentFailureCode } from "#veryfront/agent/hosted/executor-agent-schema.ts"; +import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; +import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; + +describe("executor runtime private lifecycle", () => { + it("classifies private errors without exposing them to a replaced descriptor intrinsic", () => { + const original = Object.getOwnPropertyDescriptor; + const privateError = { code: "PERMISSION_DENIED", detail: "Synthetic private detail" }; + let exposures = 0; + let code: string | undefined; + try { + Object.getOwnPropertyDescriptor = (value, key) => { + if (value === privateError) exposures++; + return original(value, key); + }; + code = executorAgentFailureCode(privateError, "EXECUTOR_AGENT_SETUP_FAILED"); + } finally { + Object.getOwnPropertyDescriptor = original; + } + assertEquals(code, "PERMISSION_DENIED"); + assertEquals(exposures, 0); + }); + + it("releases prepared facades and discovery after project code replaces promise chaining", async () => { + const binding = { allocationId: "lifecycle", invocationId: "lifecycle", generation: 1 }; + const source = { type: "release", releaseId: "synthetic-release" } as const; + const modelId = "veryfront-cloud/openai/gpt-5.4"; + const coder = agent({ + id: "coder", + system: "Synthetic source instructions.", + model: modelId, + tools: [], + }); + let facadeCleanups = 0; + let discoveryCleanups = 0; + const discovery = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + signal: new AbortController().signal, + backend: { + load: () => + Promise.resolve({ + agents: new Map([[coder.id, coder]]), + tools: new Map(), + 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" }, + }), + cleanup: () => { + discoveryCleanups++; + return Promise.resolve(); + }, + }, + }); + const owner = createExecutorRuntimePreparation({ + binding, + source, + discovery, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, + }, + facades: { + hostTools: new Map(), + remoteToolSources: new Map(), + resolveModelRuntime: () => ({ + modelId: "gpt-5.4", + provider: "openai", + specificationVersion: "v3", + doGenerate: () => Promise.reject(new Error("Unexpected generate call")), + doStream: () => Promise.reject(new Error("Unexpected stream call")), + }), + cleanup: () => { + facadeCleanups++; + return Promise.resolve(); + }, + }, + }); + const originalThen = Promise.prototype.then; + try { + const operation = owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + const result = await operation.handle({ agentId: "coder" }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }); + assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); + Promise.prototype.then = function () { + return Promise.resolve(undefined) as ReturnType; + }; + await owner.close(); + } finally { + Promise.prototype.then = originalThen; + await owner.close(); + } + assertEquals(facadeCleanups, 1); + assertEquals(discoveryCleanups, 1); + await owner.settled; + }); +}); From 0f1e69470be3de370b96db7ad5c92b2363a5cfd1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 15:51:00 +0200 Subject: [PATCH 070/194] test(agent): correct private lifecycle regression types --- .../agent/executor-runtime-private-lifecycle.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts index fd8a080c83..d248d513c5 100644 --- a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts +++ b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts @@ -29,11 +29,11 @@ describe("executor runtime private lifecycle", () => { const binding = { allocationId: "lifecycle", invocationId: "lifecycle", generation: 1 }; const source = { type: "release", releaseId: "synthetic-release" } as const; const modelId = "veryfront-cloud/openai/gpt-5.4"; - const coder = agent({ + const coder = agent({ id: "coder", system: "Synthetic source instructions.", model: modelId, - tools: [], + tools: {}, }); let facadeCleanups = 0; let discoveryCleanups = 0; @@ -104,9 +104,7 @@ describe("executor runtime private lifecycle", () => { deadline: Date.now() + 30_000, }); assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); - Promise.prototype.then = function () { - return Promise.resolve(undefined) as ReturnType; - }; + Promise.prototype.then = (() => Promise.resolve()) as typeof originalThen; await owner.close(); } finally { Promise.prototype.then = originalThen; From e7caafe8ab0e2351c14ad1329855c11d3e60ec3a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:01:46 +0200 Subject: [PATCH 071/194] fix(tool): protect facade provenance and runtime-local markers --- src/agent/runtime/local-tool.ts | 15 ++++++--------- src/tool/host-tool-provenance.ts | 15 ++++++++++----- src/tool/remote-tool-provenance.ts | 10 ++++------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/agent/runtime/local-tool.ts b/src/agent/runtime/local-tool.ts index 8f1e2a8f20..c259671a83 100644 --- a/src/agent/runtime/local-tool.ts +++ b/src/agent/runtime/local-tool.ts @@ -1,18 +1,15 @@ import type { Tool } from "#veryfront/tool"; +const objectDefineProperty = Object.defineProperty; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const AGENT_RUNTIME_LOCAL_TOOL = Symbol("veryfront.agent.runtimeLocalTool"); const SKILL_DELEGATION_OVERRIDES_UNSUPPORTED = Symbol( "veryfront.agent.skillDelegationOverridesUnsupported", ); -type RuntimeLocalTool = Tool & { - [AGENT_RUNTIME_LOCAL_TOOL]?: true; - [SKILL_DELEGATION_OVERRIDES_UNSUPPORTED]?: true; -}; - /** Mark a framework-created tool as local to one agent runtime. */ export function markRuntimeLocalTool(tool: Tool): Tool { - Object.defineProperty(tool, AGENT_RUNTIME_LOCAL_TOOL, { + objectDefineProperty(tool, AGENT_RUNTIME_LOCAL_TOOL, { value: true, enumerable: false, }); @@ -24,13 +21,13 @@ export function isRuntimeLocalTool(value: unknown): boolean { return Boolean( value && typeof value === "object" && - (value as RuntimeLocalTool)[AGENT_RUNTIME_LOCAL_TOOL] === true, + objectGetOwnPropertyDescriptor(value, AGENT_RUNTIME_LOCAL_TOOL)?.value === true, ); } /** Mark a tool whose execution contract cannot consume hosted child-run overrides. */ export function markSkillDelegationOverridesUnsupported(tool: Tool): Tool { - Object.defineProperty(tool, SKILL_DELEGATION_OVERRIDES_UNSUPPORTED, { + objectDefineProperty(tool, SKILL_DELEGATION_OVERRIDES_UNSUPPORTED, { value: true, enumerable: false, }); @@ -42,6 +39,6 @@ export function supportsSkillDelegationOverrides(value: unknown): boolean { return !( value && typeof value === "object" && - (value as RuntimeLocalTool)[SKILL_DELEGATION_OVERRIDES_UNSUPPORTED] === true + objectGetOwnPropertyDescriptor(value, SKILL_DELEGATION_OVERRIDES_UNSUPPORTED)?.value === true ); } diff --git a/src/tool/host-tool-provenance.ts b/src/tool/host-tool-provenance.ts index e2bd80cb1c..7b20b08c63 100644 --- a/src/tool/host-tool-provenance.ts +++ b/src/tool/host-tool-provenance.ts @@ -1,22 +1,27 @@ -const trustedHostTools = new WeakSet(); +import { createPrivateWeakStore } from "#veryfront/security/private-weak-store.ts"; + +const trustedHostTools = createPrivateWeakStore(); +const objectValues = Object.values; /** @internal Mark a host tool definition or materialized tool as framework-owned. */ export function markTrustedHostToolProvenance(tool: T): T { - trustedHostTools.add(tool); + trustedHostTools.set(tool, true); return tool; } /** @internal Mark every tool in a host-owned tool set as framework-owned. */ export function markTrustedHostToolSet>(tools: T): T { - for (const tool of Object.values(tools)) { - markTrustedHostToolProvenance(tool); + const values = objectValues(tools); + for (let index = 0; index < values.length; index++) { + const tool = values[index]; + if (tool !== undefined) markTrustedHostToolProvenance(tool); } return tools; } /** @internal Return whether a tool carries unforgeable framework provenance. */ export function hasTrustedHostToolProvenance(tool: unknown): boolean { - return typeof tool === "object" && tool !== null && trustedHostTools.has(tool); + return typeof tool === "object" && tool !== null && trustedHostTools.get(tool) === true; } /** @internal Copy trusted framework provenance across a host-owned wrapper. */ diff --git a/src/tool/remote-tool-provenance.ts b/src/tool/remote-tool-provenance.ts index 2668c6ddeb..17395a142a 100644 --- a/src/tool/remote-tool-provenance.ts +++ b/src/tool/remote-tool-provenance.ts @@ -1,15 +1,13 @@ const REMOTE_TOOL_PROVENANCE = Symbol("veryfront.remote-tool-provenance"); - -type RemoteToolProvenance = { - [REMOTE_TOOL_PROVENANCE]?: string; -}; +const objectDefineProperty = Object.defineProperty; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; /** Mark a runtime tool as materialized from a trusted remote tool source. */ export function markRemoteToolProvenance( tool: T, canonicalToolName: string, ): T { - Object.defineProperty(tool, REMOTE_TOOL_PROVENANCE, { + objectDefineProperty(tool, REMOTE_TOOL_PROVENANCE, { value: canonicalToolName, enumerable: true, }); @@ -22,7 +20,7 @@ export function getRemoteToolProvenance(value: unknown): string | undefined { return undefined; } - const canonicalToolName = (value as RemoteToolProvenance)[REMOTE_TOOL_PROVENANCE]; + const canonicalToolName = objectGetOwnPropertyDescriptor(value, REMOTE_TOOL_PROVENANCE)?.value; return typeof canonicalToolName === "string" && canonicalToolName.length > 0 ? canonicalToolName : undefined; From 5a53f3650dcf24621dbba926d6f1d4dd070b1a4c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:04:57 +0200 Subject: [PATCH 072/194] test(agent): guard private tool provenance against replaced intrinsics --- ...runtime-tool-provenance-intrinsics.test.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts diff --git a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts new file mode 100644 index 0000000000..e80a9a097b --- /dev/null +++ b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts @@ -0,0 +1,97 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { createToolsFromHostDefinitions } from "#veryfront/tool/host-tools.ts"; +import { + hasTrustedHostToolProvenance, + inheritTrustedHostToolProvenance, + markTrustedHostToolProvenance, + markTrustedHostToolSet, +} from "#veryfront/tool/host-tool-provenance.ts"; +import { + isRuntimeLocalTool, + markRuntimeLocalTool, + markSkillDelegationOverridesUnsupported, + supportsSkillDelegationOverrides, +} from "#veryfront/agent/runtime/local-tool.ts"; +import { + getRemoteToolProvenance, + markRemoteToolProvenance, +} from "#veryfront/tool/remote-tool-provenance.ts"; + +describe("runtime tool provenance intrinsics", () => { + it("keeps trusted tools private when weak collection methods and object enumeration are replaced", () => { + const source = markTrustedHostToolProvenance({ execute: () => ({ ok: true }) }); + const target = { execute: source.execute }; + const tools = { source }; + const originalHas = WeakSet.prototype.has; + const originalAdd = WeakSet.prototype.add; + const originalGet = WeakMap.prototype.get; + const originalSet = WeakMap.prototype.set; + const originalValues = Object.values; + let exposures = 0; + try { + WeakSet.prototype.has = function (value) { + if (value === source || value === target) exposures++; + return originalHas.call(this, value); + }; + WeakSet.prototype.add = function (value) { + if (value === source || value === target) exposures++; + return originalAdd.call(this, value); + }; + WeakMap.prototype.get = function (key) { + if (key === source || key === target) exposures++; + return originalGet.call(this, key); + }; + WeakMap.prototype.set = function (key, value) { + if (key === source || key === target) exposures++; + return originalSet.call(this, key, value); + }; + Object.values = (value) => { + if (value === tools) exposures++; + return originalValues(value); + }; + markTrustedHostToolSet(tools); + inheritTrustedHostToolProvenance(source, target); + } finally { + WeakSet.prototype.has = originalHas; + WeakSet.prototype.add = originalAdd; + WeakMap.prototype.get = originalGet; + WeakMap.prototype.set = originalSet; + Object.values = originalValues; + } + assertEquals(exposures, 0); + assertEquals(hasTrustedHostToolProvenance(target), true); + assertEquals(hasTrustedHostToolProvenance({}), false); + }); + + it("marks runtime and remote tools without exposing them to a replaced property intrinsic", () => { + const tools = createToolsFromHostDefinitions({ + private: { + description: "Synthetic private tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }, + }); + const tool = tools.private; + assert(tool); + const original = Object.defineProperty; + let exposures = 0; + try { + Object.defineProperty = (value, key, descriptor) => { + if (value === tool) exposures++; + return original(value, key, descriptor); + }; + markRuntimeLocalTool(tool); + markSkillDelegationOverridesUnsupported(tool); + markRemoteToolProvenance(tool, "synthetic_remote"); + } finally { + Object.defineProperty = original; + } + assertEquals(exposures, 0); + assertEquals(isRuntimeLocalTool(tool), true); + assertEquals(supportsSkillDelegationOverrides(tool), false); + assertEquals(getRemoteToolProvenance(tool), "synthetic_remote"); + }); +}); From 549df380579cdda3bd3a92609e88b8c38438ebda Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:06:44 +0200 Subject: [PATCH 073/194] test(agent): type provenance regression replacement parameter --- .../agent/runtime-tool-provenance-intrinsics.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts index e80a9a097b..a082f9edca 100644 --- a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts +++ b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts @@ -48,7 +48,7 @@ describe("runtime tool provenance intrinsics", () => { if (key === source || key === target) exposures++; return originalSet.call(this, key, value); }; - Object.values = (value) => { + Object.values = (value: Parameters[0]) => { if (value === tools) exposures++; return originalValues(value); }; From 28a4eb75504de661604e415d3287bc8e5d5ddccd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:24:49 +0200 Subject: [PATCH 074/194] fix(agent): protect cancellation, completion, and authored runtime limits --- src/agent/hosted/executor-discovery.ts | 12 +++++-- src/agent/hosted/executor-runtime-prepare.ts | 18 +++++++--- .../hosted/runtime-request-config.test.ts | 25 +++++++++++++- src/agent/hosted/runtime-request-config.ts | 34 ++++++++++++------- src/agent/runtime/index.ts | 5 +-- src/security/private-promise.ts | 6 ++++ 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 20171a057c..c1d2593108 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -1,6 +1,9 @@ import { isAbsolute, join, relative, sep } from "node:path"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; -import { chainPrivatePromise as chain } from "#veryfront/security/private-promise.ts"; +import { + chainPrivatePromise as chain, + createPrivateDeferred, +} from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ProjectAgentRuntimeAgentSource, @@ -27,6 +30,9 @@ import { parseDiscoveryData, } from "./executor-discovery-schema.ts"; +const apply = Reflect.apply; +const abortController = AbortController.prototype.abort; + export interface ExecutorDiscoveryBackend { load(signal: AbortSignal): Promise; /** Own partial setup even when load throws before returning a runtime. */ @@ -83,7 +89,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut ) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_INPUT"); const projectDir = input.projectDir; const lifetime = new AbortController(); - const settled = Promise.withResolvers(); + const settled = createPrivateDeferred(); void chain(settled.promise, () => {}, () => {}); let tail: Promise = Promise.resolve(); let backend = input.backend; @@ -121,7 +127,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut }); void chain(closing, settled.resolve, settled.reject); input.signal.removeEventListener("abort", onAbort); - lifetime.abort(); + apply(abortController, lifetime, []); return closing; } const onAbort = () => { diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 7c14415311..bc49b14b6e 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -1,6 +1,7 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { chainPrivatePromise as chain, + createPrivateDeferred, resolvePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; @@ -80,6 +81,10 @@ const hasOwn = Object.hasOwn; const objectDefineProperty = Object.defineProperty; const objectEntries = Object.entries; const arrayIncludes = Array.prototype.includes; +const abortController = AbortController.prototype.abort; +const abortSignalAny = AbortSignal.any; +const AbortSignalConstructor = AbortSignal; +const mathMin = Math.min; function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { const filtered: T[] = []; @@ -233,7 +238,7 @@ export function createExecutorRuntimePreparation(input: Options) { let startup: Promise> | undefined; let producerCompletion: Promise | undefined; let streamSignal = lifetime.signal; - const settled = Promise.withResolvers(); + const settled = createPrivateDeferred(); void chain(settled.promise, () => {}, () => {}); const assertActive = () => { if (lifetime.signal.aborted || input.discovery.signal.aborted) { @@ -269,7 +274,7 @@ export function createExecutorRuntimePreparation(input: Options) { if (failed) refuse("EXECUTOR_RUNTIME_CLEANUP_FAILED"); }); void chain(closing, settled.resolve, settled.reject); - lifetime.abort(); + apply(abortController, lifetime, []); input.discovery.signal.removeEventListener("abort", onDiscoveryAbort); return closing; } @@ -491,7 +496,7 @@ export function createExecutorRuntimePreparation(input: Options) { : definition.system ?? definition.instructions), temperature: request.temperature ?? definition.temperature, thinking: request.thinking ?? definition.thinking, - maxSteps: Math.min( + maxSteps: mathMin( request.maxSteps ?? grant.maxSteps, definition.maxSteps ?? grant.maxSteps, grant.maxSteps, @@ -692,7 +697,10 @@ export function createExecutorRuntimePreparation(input: Options) { context.signal.addEventListener("abort", cancel, { once: true }); preparation = prepare(request, { ...context, - signal: AbortSignal.any([context.signal, lifetime.signal]), + signal: apply(abortSignalAny, AbortSignalConstructor, [[ + context.signal, + lifetime.signal, + ]]), }); if (context.signal.aborted) cancel(); try { @@ -721,7 +729,7 @@ export function createExecutorRuntimePreparation(input: Options) { if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); yield* operation.handle(value, { ...context, - signal: AbortSignal.any([context.signal, lifetime.signal]), + signal: apply(abortSignalAny, AbortSignalConstructor, [[context.signal, lifetime.signal]]), }); }, }); diff --git a/src/agent/hosted/runtime-request-config.test.ts b/src/agent/hosted/runtime-request-config.test.ts index f028874038..2effe1e5c3 100644 --- a/src/agent/hosted/runtime-request-config.test.ts +++ b/src/agent/hosted/runtime-request-config.test.ts @@ -7,9 +7,32 @@ import { getForwardedHostedRuntimeOverrides, getServerResolvedProviderReplayCheckpoints, getServerResolvedToolExposureCheckpoint, + resolveHostedRuntimeAllowedTools, resolveHostedRuntimeRequestConfig, resolveHostedRuntimeThinkingOverride, -} from "./runtime-request-config.ts"; +} from "#veryfront/agent/hosted/runtime-request-config.ts"; + +describe("authored tool selector snapshots", () => { + it("reads configured tools and delegates by index without invoking collection hooks", () => { + const tools = ["read_file"]; + const delegates = ["writer"]; + Object.defineProperty(tools, Symbol.iterator, { + value: () => ["hidden_tool"].values(), + }); + Object.defineProperty(delegates, "map", { + value: () => ["agent_hidden"], + }); + assertEquals( + resolveHostedRuntimeAllowedTools({ + configuredTools: tools, + configuredDelegates: delegates, + configuredSkills: [], + requestedTools: undefined, + }), + ["read_file", "agent_writer"], + ); + }); +}); it("server-resolved tool exposure checkpoint parses strictly and fails closed", () => { const checkpoint = { diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 773f02aa7f..48600b8637 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -20,6 +20,8 @@ import { type ProviderReplayCheckpoint, } from "../runtime/provider-replay.ts"; +const arrayIsArray = Array.isArray; + /** Request payload for hosted runtime request config. */ export type HostedRuntimeRequestConfigRequest = Pick< HostedChatRequest, @@ -219,21 +221,27 @@ export function resolveHostedRuntimeAllowedTools(input: { : [...createPrivateSet(input.requestedTools)]; } - const configuredToolNames = createPrivateSet([ - ...(input.configuredTools ?? []), - ...(input.configuredDelegates ?? []).map((id) => `${AGENT_DELEGATE_TOOL_PREFIX}${id}`), - ]); + const configuredToolNames = createPrivateSet(input.configuredTools ?? []); + const delegates = input.configuredDelegates ?? []; + for (let index = 0; index < delegates.length; index++) { + const id = delegates[index]; + if (id !== undefined) configuredToolNames.add(`${AGENT_DELEGATE_TOOL_PREFIX}${id}`); + } if (input.requestedTools === undefined) { return [...configuredToolNames]; } const hasImplicitLegacyDelegation = input.configuredSkills === undefined || input.configuredSkills === true || - (Array.isArray(input.configuredSkills) && input.configuredSkills.length > 0); - return [...createPrivateSet(input.requestedTools)].filter((toolName) => - configuredToolNames.has(toolName) || - (toolName === "invoke_agent" && hasImplicitLegacyDelegation) - ); + (arrayIsArray(input.configuredSkills) && input.configuredSkills.length > 0); + const selectedToolNames = createPrivateSet(); + for (const toolName of createPrivateSet(input.requestedTools)) { + if ( + configuredToolNames.has(toolName) || + (toolName === "invoke_agent" && hasImplicitLegacyDelegation) + ) selectedToolNames.add(toolName); + } + return [...selectedToolNames]; } /** Resolve provider-native tool bindings without widening direct tool access. */ @@ -246,9 +254,11 @@ export function resolveHostedRuntimeAllowedProviderTools(input: { return [...configuredToolNames]; } - return [...createPrivateSet(input.requestedTools)].filter((toolName) => - configuredToolNames.has(toolName) - ); + const selectedToolNames = createPrivateSet(); + for (const toolName of createPrivateSet(input.requestedTools)) { + if (configuredToolNames.has(toolName)) selectedToolNames.add(toolName); + } + return [...selectedToolNames]; } /** Configuration used by resolve hosted runtime request. */ diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index c5be7f67a0..ec8f640cee 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -11,6 +11,7 @@ * @module ai/agent/runtime */ +import { createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { enterSerializedTurn, withRuntimeTurnLineage, @@ -2029,7 +2030,7 @@ export class AgentRuntime { throw error; } let isFinalized = false; - const finalization = Promise.withResolvers(); + const finalization = createPrivateDeferred(); return { messages: persisted.length > 0 ? persisted : committedInputMessages, addMessage: (message) => turnMemory.add(message), @@ -2465,7 +2466,7 @@ export class AgentRuntime { // an unhandled rejection under Deno (#2334). let inFlight: Promise | undefined; - const completion = Promise.withResolvers(); + const completion = createPrivateDeferred(); this.#onStreamCompletion?.(completion.promise); const runtimeStream = new IntrinsicReadableStream({ start: async (controller) => { diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index 05a1f1e2dd..ce3392033e 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -1,6 +1,7 @@ const PromiseConstructor = Promise; const apply = Reflect.apply; const promiseResolve = Promise.resolve; +const promiseWithResolvers = Promise.withResolvers; /** Await owned native promises without dispatching through replaced promise methods. */ export async function chainPrivatePromise( @@ -22,3 +23,8 @@ export async function chainPrivatePromise( export function resolvePrivatePromise(): Promise { return apply(promiseResolve, PromiseConstructor, []) as Promise; } + +/** Create an owned completion latch without consulting a replaced constructor helper. */ +export function createPrivateDeferred(): PromiseWithResolvers { + return apply(promiseWithResolvers, PromiseConstructor, []) as PromiseWithResolvers; +} From 58cacbf8bb90a333ac2e1de343718ae0bdd35c3b Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 14:23:10 +0200 Subject: [PATCH 075/194] feat(agent): install isolated executor runtime through bound capabilities --- deno.json | 1 + scripts/test/coverage-node-executor.mjs | 2 + src/agent/hosted/executor-node-bootstrap.ts | 5 +- .../executor-persistence-bridge.test.ts | 317 ++++++++++++++++++ .../hosted/executor-persistence-bridge.ts | 275 +++++++++++++++ .../hosted/executor-persistence-schema.ts | 132 ++++++++ .../hosted/executor-runtime-entrypoint.ts | 156 +++++++++ .../hosted/executor-runtime-facades.test.ts | 179 ++++++++++ src/agent/hosted/executor-runtime-facades.ts | 98 ++++++ .../hosted/executor-runtime-install-schema.ts | 95 ++++++ .../hosted/executor-runtime-install.test.ts | 231 +++++++++++++ src/agent/hosted/executor-runtime-install.ts | 178 ++++++++++ .../hosted/executor-state-bridge.test.ts | 269 +++++++++++++++ src/agent/hosted/executor-state-bridge.ts | 242 +++++++++++++ src/agent/hosted/executor-state-schema.ts | 149 ++++++++ tests/fixtures/executor-runtime-process.ts | 35 ++ .../agent/executor-runtime-entrypoint.test.ts | 224 +++++++++++++ 17 files changed, 2586 insertions(+), 2 deletions(-) create mode 100644 src/agent/hosted/executor-persistence-bridge.test.ts create mode 100644 src/agent/hosted/executor-persistence-bridge.ts create mode 100644 src/agent/hosted/executor-persistence-schema.ts create mode 100644 src/agent/hosted/executor-runtime-entrypoint.ts create mode 100644 src/agent/hosted/executor-runtime-facades.test.ts create mode 100644 src/agent/hosted/executor-runtime-facades.ts create mode 100644 src/agent/hosted/executor-runtime-install-schema.ts create mode 100644 src/agent/hosted/executor-runtime-install.test.ts create mode 100644 src/agent/hosted/executor-runtime-install.ts create mode 100644 src/agent/hosted/executor-state-bridge.test.ts create mode 100644 src/agent/hosted/executor-state-bridge.ts create mode 100644 src/agent/hosted/executor-state-schema.ts create mode 100644 tests/fixtures/executor-runtime-process.ts create mode 100644 tests/integration/agent/executor-runtime-entrypoint.test.ts diff --git a/deno.json b/deno.json index e003572f72..95e17e5c01 100644 --- a/deno.json +++ b/deno.json @@ -99,6 +99,7 @@ "./markdown": "./src/markdown/index.ts", "./mdx": "./src/mdx/index.ts", "./agent": "./src/agent/index.ts", + "./agent/executor-runtime": "./src/agent/hosted/executor-runtime-entrypoint.ts", "./agent/identity": "./src/agent/identity-contracts.ts", "./eval": "./src/eval/index.ts", "./metrics": "./src/metrics/public.ts", diff --git a/scripts/test/coverage-node-executor.mjs b/scripts/test/coverage-node-executor.mjs index 33a355a42a..38b276c13c 100644 --- a/scripts/test/coverage-node-executor.mjs +++ b/scripts/test/coverage-node-executor.mjs @@ -10,6 +10,7 @@ const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const SOURCE_FILES = [ "src/agent/hosted/executor-allocator-client.ts", "src/agent/hosted/executor-node-bootstrap.ts", + "src/agent/hosted/executor-runtime-entrypoint.ts", "src/agent/hosted/executor-node-transport.ts", "src/security/http/native-header-processing.ts", "src/security/http/native-request-processing.ts", @@ -17,6 +18,7 @@ const SOURCE_FILES = [ const TEST_FILES = [ "tests/integration/agent/executor-allocator-client.test.ts", "tests/integration/agent/executor-node-bootstrap.test.ts", + "tests/integration/agent/executor-runtime-entrypoint.test.ts", "tests/integration/agent/executor-node-transport.test.ts", "tests/integration/agent/service-header-boundary.test.ts", "tests/integration/agent/service-request-defaults.test.ts", diff --git a/src/agent/hosted/executor-node-bootstrap.ts b/src/agent/hosted/executor-node-bootstrap.ts index c1e5680a6e..f178e77fb8 100644 --- a/src/agent/hosted/executor-node-bootstrap.ts +++ b/src/agent/hosted/executor-node-bootstrap.ts @@ -47,7 +47,8 @@ export interface ExecutorNodeBootstrap { const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -function readBootstrap(environment: ExecutorBootstrapEnvironment) { +/** Validate the fixed Operator environment without enumerating or forwarding it. */ +export function readExecutorBootstrapConfiguration(environment: ExecutorBootstrapEnvironment) { try { const allocationId = environment.get("VERYFRONT_EXECUTOR_ALLOCATION_ID"); const generation = environment.get("VERYFRONT_EXECUTOR_GENERATION"); @@ -144,7 +145,7 @@ export async function startExecutorNodeBootstrap( Number(process.versions.node.split(".")[0]) < 22 ) throw new Error("Executor bootstrap requires Node.js 22 or newer"); const startedAt = Date.now(); - const { binding, lifetimeMs, hardDeadlineAt } = readBootstrap( + const { binding, lifetimeMs, hardDeadlineAt } = readExecutorBootstrapConfiguration( options.environment ?? { get: (name) => process.env[name] }, ); const deadline = Math.min(startedAt + lifetimeMs, hardDeadlineAt); diff --git a/src/agent/hosted/executor-persistence-bridge.test.ts b/src/agent/hosted/executor-persistence-bridge.test.ts new file mode 100644 index 0000000000..eee12da04d --- /dev/null +++ b/src/agent/hosted/executor-persistence-bridge.test.ts @@ -0,0 +1,317 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import { + createExecutorPersistenceBroker, + createExecutorPersistenceFacades, +} from "./executor-persistence-bridge.ts"; +import { + executorPersistenceJson, + executorPersistenceOperations, + getExecutorPersistenceCapabilityIdsSchema, +} from "./executor-persistence-schema.ts"; + +const binding = { + allocationId: "allocation-persistence-test", + generation: 7, + invocationId: "invocation-persistence-test", +}; +const capabilityIds = { + publishParentRunEvents: "parent-events-capability", + toolExposureCheckpoint: "tool-checkpoint-capability", + providerReplayCheckpoint: "provider-checkpoint-capability", +}; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function pair(operations: ReadonlyMap) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const executor = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + }); + return { + executor, + broker, + async close() { + executor.close(); + await broker.closed; + await Promise.all([executor.settled, broker.settled]); + }, + }; +} + +describe("executor persistence bridge", () => { + it("acknowledges actual persistence in one invocation order", async () => { + const persisted: string[] = []; + const channels = pair(createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds, + publishParentRunEvents: async (events) => { + persisted.push(`events:${events[0]?.type}`); + }, + persistToolExposureCheckpoint: async (checkpoint) => { + persisted.push(`tools:${checkpoint.loadedToolNames.join(",")}`); + }, + persistProviderReplayCheckpoint: async (checkpoint) => { + persisted.push(`provider:${checkpoint.messageId}`); + }, + })); + try { + const facades = createExecutorPersistenceFacades({ + channel: channels.executor, + capabilityIds, + }); + await Promise.all([ + facades.publishParentRunEvents?.([{ type: "STEP_STARTED" }]), + facades.toolExposureCheckpoint?.persist({ version: 2, loadedToolNames: ["search"] }), + facades.providerReplayCheckpoint?.persist({ + version: 1, + messageId: "message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "synthetic" }, + }], + providerBlockPositions: [0], + providerMessageBlockCounts: [1], + totalPartCount: 1, + }), + ]); + assertEquals(persisted, ["events:STEP_STARTED", "tools:search", "provider:message-1"]); + } finally { + await channels.close(); + } + }); + + it("exports strict install capability IDs and never accepts authority fields", () => { + assertEquals(getExecutorPersistenceCapabilityIdsSchema().parse(capabilityIds), capabilityIds); + for (const field of ["runId", "ownerId", "authToken", "url"]) { + const result = getExecutorPersistenceCapabilityIdsSchema().safeParse({ + ...capabilityIds, + [field]: "synthetic-secret", + }); + assertEquals(result.success, false); + } + assertEquals( + getExecutorPersistenceCapabilityIdsSchema().safeParse({ + publishParentRunEvents: "duplicate-capability", + toolExposureCheckpoint: "duplicate-capability", + }).success, + false, + ); + }); + + it("does not consume sequence numbers for locally invalid or remotely unauthorized requests", async () => { + const persisted: string[] = []; + const operations = createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, + publishParentRunEvents: async (events) => { + persisted.push(String(events[0]?.type)); + }, + }); + const operation = operations.get(executorPersistenceOperations.publishParentRunEvents); + if (operation?.mode !== "unary") throw new Error("missing synthetic operation"); + await assertRejects(() => + Promise.resolve(operation.handle({ + capabilityId: "wrong-capability", + sequence: 1, + events: [{ type: "UNAUTHORIZED" }], + }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + })) + ); + + const channels = pair(operations); + try { + const facades = createExecutorPersistenceFacades({ + channel: channels.executor, + capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, + }); + const cyclic: Record = { type: "INVALID" }; + cyclic.self = cyclic; + await assertRejects(() => + facades.publishParentRunEvents!( + [cyclic] as unknown as Parameters>[0], + ) + ); + await facades.publishParentRunEvents!([{ type: "AUTHORIZED" }]); + assertEquals(persisted, ["AUTHORIZED"]); + } finally { + await channels.close(); + } + }); + + it("withholds acknowledgement and channel settlement until the original write settles", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const channels = pair(createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, + publishParentRunEvents: async () => { + entered.resolve(); + await release.promise; + }, + })); + const facades = createExecutorPersistenceFacades({ + channel: channels.executor, + capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, + }); + let acknowledged = false; + const request = facades.publishParentRunEvents!([{ type: "STEP_FINISHED" }]).then(() => { + acknowledged = true; + }); + await entered.promise; + await tick(); + assertEquals(acknowledged, false); + + channels.executor.close(); + await channels.broker.closed; + let settled = false; + void channels.broker.settled.then(() => settled = true); + await assertRejects(() => request); + await tick(); + assertEquals(settled, false); + release.resolve(); + await Promise.all([channels.executor.settled, channels.broker.settled]); + assertEquals(settled, true); + }); + + it("fails closed on incomplete capabilities and validates initial checkpoint copies", () => { + assertThrows(() => + createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { toolExposureCheckpoint: capabilityIds.toolExposureCheckpoint }, + }) + ); + assertThrows(() => + createExecutorPersistenceFacades({ + channel: {} as Parameters[0]["channel"], + capabilityIds: {}, + initialToolExposureCheckpoint: { version: 2, loadedToolNames: ["search"] }, + }) + ); + }); + + it("consumes accepted failed writes once and never retries them", async () => { + const persisted: string[] = []; + const operations = createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, + publishParentRunEvents: async (events) => { + persisted.push(String(events[0]?.type)); + if (events[0]?.type === "FIRST") throw new Error("synthetic persistence failure"); + }, + }); + const operation = operations.get(executorPersistenceOperations.publishParentRunEvents); + if (operation?.mode !== "unary") throw new Error("missing synthetic operation"); + const context = { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + }; + await assertRejects(() => + Promise.resolve(operation.handle({ + capabilityId: capabilityIds.publishParentRunEvents, + sequence: 1, + events: [{ type: "FIRST" }], + }, context)) + ); + await assertRejects(() => + Promise.resolve(operation.handle({ + capabilityId: capabilityIds.publishParentRunEvents, + sequence: 1, + events: [{ type: "RETRY" }], + }, context)) + ); + assertEquals( + await operation.handle({ + capabilityId: capabilityIds.publishParentRunEvents, + sequence: 2, + events: [{ type: "NEXT" }], + }, context), + { acknowledged: true, sequence: 2 }, + ); + assertEquals(persisted, ["FIRST", "NEXT"]); + }); + + it("rejects malformed replay state and mismatched bindings without reserving sequence", async () => { + let writes = 0; + const operations = createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { providerReplayCheckpoint: capabilityIds.providerReplayCheckpoint }, + persistProviderReplayCheckpoint: () => { + writes++; + }, + }); + const operation = operations.get(executorPersistenceOperations.persistProviderReplayCheckpoint); + if (operation?.mode !== "unary") throw new Error("missing synthetic operation"); + const checkpoint = { + version: 1, + messageId: "message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "synthetic" }, + }], + providerBlockPositions: [0], + providerMessageBlockCounts: [1], + totalPartCount: 1, + } as const; + await assertRejects(() => + Promise.resolve(operation.handle( + executorPersistenceJson({ + capabilityId: capabilityIds.providerReplayCheckpoint, + sequence: 1, + checkpoint: { ...checkpoint, providerBlockPositions: [1] }, + }), + { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + }, + )) + ); + await assertRejects(() => + Promise.resolve(operation.handle( + executorPersistenceJson({ + capabilityId: capabilityIds.providerReplayCheckpoint, + sequence: 1, + checkpoint, + }), + { + binding: { ...binding, invocationId: "other-invocation" }, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + }, + )) + ); + assertEquals( + await operation.handle( + executorPersistenceJson({ + capabilityId: capabilityIds.providerReplayCheckpoint, + sequence: 1, + checkpoint, + }), + { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + }, + ), + { acknowledged: true, sequence: 1 }, + ); + assertEquals(writes, 1); + }); +}); diff --git a/src/agent/hosted/executor-persistence-bridge.ts b/src/agent/hosted/executor-persistence-bridge.ts new file mode 100644 index 0000000000..7eedb2ce75 --- /dev/null +++ b/src/agent/hosted/executor-persistence-bridge.ts @@ -0,0 +1,275 @@ +import type { ConversationRunEvent } from "#veryfront/agent/conversation/run-events.ts"; +import type { Schema } from "#veryfront/extensions/schema/index.ts"; +import type { ProviderReplayCheckpoint } from "#veryfront/agent/runtime/provider-replay.ts"; +import { + parseProviderReplayCheckpoint, + parseServerResolvedProviderReplayCheckpoints, +} from "#veryfront/agent/runtime/provider-replay.ts"; +import type { ToolExposureCheckpoint } from "#veryfront/agent/runtime/tool-exposure.ts"; +import type { ExecutorBinding } from "../executor/protocol.ts"; +import { getExecutorBindingSchema } from "../executor/protocol.ts"; +import type { + ExecutorChannel, + ExecutorOperation, + ExecutorOperationContext, +} from "../executor/channel.ts"; +import type { ExecutorRuntimeFacades } from "./executor-runtime-prepare.ts"; +import { + type ExecutorPersistenceCapabilityIds, + executorPersistenceJson, + executorPersistenceOperations, + getExecutorParentRunEventsRequestSchema, + getExecutorPersistenceAckSchema, + getExecutorPersistenceCapabilityIdsSchema, + getExecutorProviderReplayCheckpointRequestSchema, + getExecutorToolExposureCheckpointRequestSchema, + getExecutorToolExposureCheckpointSchema, + parseExecutorPersistenceData, +} from "./executor-persistence-schema.ts"; +export type { ExecutorPersistenceCapabilityIds } from "./executor-persistence-schema.ts"; + +/** + * Non-owning views over an authenticated channel. Each promise resolves only + * after a broker acknowledgement. The session owner closes the shared channel + * and retains `channel.settled`; these facades own no separate cleanup task. + */ +export type ExecutorPersistenceFacades = Pick< + ExecutorRuntimeFacades, + "publishParentRunEvents" | "toolExposureCheckpoint" | "providerReplayCheckpoint" +>; + +function sameBinding(left: Readonly, right: Readonly): boolean { + return left.allocationId === right.allocationId && left.generation === right.generation && + left.invocationId === right.invocationId; +} + +function snapshotCapabilityIds( + value: ExecutorPersistenceCapabilityIds, +): ExecutorPersistenceCapabilityIds { + const result = parseExecutorPersistenceData(getExecutorPersistenceCapabilityIdsSchema(), value); + return Object.freeze(result); +} + +async function awaitPersistence( + persistence: Promise, + signal: AbortSignal, +): Promise { + const aborted = Promise.withResolvers(); + const onAbort = () => aborted.reject(new TypeError("Managed persistence cancelled")); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + try { + await Promise.race([persistence, aborted.promise]); + signal.throwIfAborted(); + } finally { + signal.removeEventListener("abort", onAbort); + // Cancellation releases the caller independently. The handler retains the + // original write so channel settlement still represents durable settlement. + await persistence.catch(() => {}); + } +} + +/** + * Install trusted persistence handlers. Run identity and credentials remain in + * the supplied closures and are absent from every strict channel DTO. + */ +export function createExecutorPersistenceBroker(options: { + expectedBinding: ExecutorBinding; + capabilityIds: ExecutorPersistenceCapabilityIds; + publishParentRunEvents?: NonNullable; + persistToolExposureCheckpoint?: NonNullable< + NonNullable["persist"] + >; + persistProviderReplayCheckpoint?: NonNullable< + NonNullable["persist"] + >; +}): ReadonlyMap { + const expectedBinding = Object.freeze(getExecutorBindingSchema().parse(options.expectedBinding)); + const capabilityIds = snapshotCapabilityIds(options.capabilityIds); + const definitions = [ + ["publishParentRunEvents", options.publishParentRunEvents], + ["toolExposureCheckpoint", options.persistToolExposureCheckpoint], + ["providerReplayCheckpoint", options.persistProviderReplayCheckpoint], + ] as const; + for (const [name, callback] of definitions) { + if ((capabilityIds[name] === undefined) !== (callback === undefined)) { + throw new TypeError("Managed persistence capability configuration is incomplete"); + } + } + + let expectedSequence = 1; + let persistenceTail = Promise.resolve(); + const authorize = ( + capabilityId: string, + sequence: number, + expectedCapabilityId: string, + context: ExecutorOperationContext, + ) => { + if ( + !sameBinding(expectedBinding, context.binding) || capabilityId !== expectedCapabilityId || + sequence !== expectedSequence + ) throw new TypeError("Managed persistence operation is not authorized"); + expectedSequence++; + }; + const persist = async ( + sequence: number, + context: ExecutorOperationContext, + write: () => void | Promise, + ) => { + const persistence = persistenceTail.then(write); + persistenceTail = persistence.catch(() => {}); + await awaitPersistence(persistence, context.signal); + return executorPersistenceJson({ acknowledged: true, sequence }); + }; + const operations = new Map(); + if (capabilityIds.publishParentRunEvents && options.publishParentRunEvents) { + const capabilityId = capabilityIds.publishParentRunEvents; + const publish = options.publishParentRunEvents; + operations.set(executorPersistenceOperations.publishParentRunEvents, { + mode: "unary", + async handle(value, context) { + const request = parseExecutorPersistenceData( + getExecutorParentRunEventsRequestSchema(), + value, + ); + authorize(request.capabilityId, request.sequence, capabilityId, context); + return await persist(request.sequence, context, () => publish(request.events)); + }, + }); + } + if (capabilityIds.toolExposureCheckpoint && options.persistToolExposureCheckpoint) { + const capabilityId = capabilityIds.toolExposureCheckpoint; + const persistCheckpoint = options.persistToolExposureCheckpoint; + operations.set(executorPersistenceOperations.persistToolExposureCheckpoint, { + mode: "unary", + async handle(value, context) { + const request = parseExecutorPersistenceData( + getExecutorToolExposureCheckpointRequestSchema(), + value, + ); + authorize(request.capabilityId, request.sequence, capabilityId, context); + return await persist( + request.sequence, + context, + () => persistCheckpoint(request.checkpoint), + ); + }, + }); + } + if (capabilityIds.providerReplayCheckpoint && options.persistProviderReplayCheckpoint) { + const capabilityId = capabilityIds.providerReplayCheckpoint; + const persistCheckpoint = options.persistProviderReplayCheckpoint; + operations.set(executorPersistenceOperations.persistProviderReplayCheckpoint, { + mode: "unary", + async handle(value, context) { + const request = parseExecutorPersistenceData( + getExecutorProviderReplayCheckpointRequestSchema(), + value, + ); + const checkpoint = parseProviderReplayCheckpoint(request.checkpoint); + authorize(request.capabilityId, request.sequence, capabilityId, context); + return await persist(request.sequence, context, () => persistCheckpoint(checkpoint)); + }, + }); + } + return operations; +} + +/** Create executor-local facades over one authenticated invocation channel. */ +export function createExecutorPersistenceFacades(options: { + channel: ExecutorChannel; + capabilityIds: ExecutorPersistenceCapabilityIds; + /** Optional view lifetime; does not transfer ownership of the shared channel. */ + signal?: AbortSignal; + initialToolExposureCheckpoint?: ToolExposureCheckpoint; + initialProviderReplayCheckpoints?: readonly ProviderReplayCheckpoint[]; +}): ExecutorPersistenceFacades { + const capabilityIds = snapshotCapabilityIds(options.capabilityIds); + if (options.initialToolExposureCheckpoint && !capabilityIds.toolExposureCheckpoint) { + throw new TypeError("Managed tool checkpoint capability is required"); + } + if (options.initialProviderReplayCheckpoints && !capabilityIds.providerReplayCheckpoint) { + throw new TypeError("Managed provider checkpoint capability is required"); + } + const initialToolExposureCheckpoint = options.initialToolExposureCheckpoint === undefined + ? undefined + : parseExecutorPersistenceData( + getExecutorToolExposureCheckpointSchema(), + executorPersistenceJson(options.initialToolExposureCheckpoint), + ); + const initialProviderReplayCheckpoints = options.initialProviderReplayCheckpoints === undefined + ? undefined + : parseServerResolvedProviderReplayCheckpoints( + executorPersistenceJson(options.initialProviderReplayCheckpoints), + ); + let sequence = 0; + const request = async ( + operation: string, + capabilityId: string, + payload: object, + schema: Schema, + ) => { + options.signal?.throwIfAborted(); + if (sequence === Number.MAX_SAFE_INTEGER) { + throw new TypeError("Managed persistence call limit exceeded"); + } + const callSequence = sequence + 1; + const input = executorPersistenceJson({ capabilityId, sequence: callSequence, ...payload }); + parseExecutorPersistenceData(schema, input); + sequence = callSequence; + const result = await options.channel.request( + operation, + input, + { signal: options.signal }, + ); + const ack = parseExecutorPersistenceData(getExecutorPersistenceAckSchema(), result); + if (ack.sequence !== callSequence) { + throw new TypeError("Invalid managed persistence acknowledgement"); + } + }; + return { + ...(capabilityIds.publishParentRunEvents + ? { + publishParentRunEvents: (events: ConversationRunEvent[]) => + request( + executorPersistenceOperations.publishParentRunEvents, + capabilityIds.publishParentRunEvents!, + { events }, + getExecutorParentRunEventsRequestSchema(), + ), + } + : {}), + ...(capabilityIds.toolExposureCheckpoint + ? { + toolExposureCheckpoint: { + initial: initialToolExposureCheckpoint, + persist: (checkpoint: ToolExposureCheckpoint) => + request( + executorPersistenceOperations.persistToolExposureCheckpoint, + capabilityIds.toolExposureCheckpoint!, + { checkpoint }, + getExecutorToolExposureCheckpointRequestSchema(), + ), + }, + } + : {}), + ...(capabilityIds.providerReplayCheckpoint + ? { + providerReplayCheckpoint: { + initial: initialProviderReplayCheckpoints, + persist: (checkpoint: ProviderReplayCheckpoint) => { + const parsed = parseProviderReplayCheckpoint( + executorPersistenceJson(checkpoint), + ); + return request( + executorPersistenceOperations.persistProviderReplayCheckpoint, + capabilityIds.providerReplayCheckpoint!, + { checkpoint: parsed }, + getExecutorProviderReplayCheckpointRequestSchema(), + ); + }, + }, + } + : {}), + }; +} diff --git a/src/agent/hosted/executor-persistence-schema.ts b/src/agent/hosted/executor-persistence-schema.ts new file mode 100644 index 0000000000..fb6cfaff4a --- /dev/null +++ b/src/agent/hosted/executor-persistence-schema.ts @@ -0,0 +1,132 @@ +import type { InferSchema, Schema } 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 { getConversationRunEventSchema } from "#veryfront/agent/conversation/run-events.ts"; +import { MAX_CONVERSATION_RUN_EVENT_PAYLOAD_BYTES } from "#veryfront/agent/conversation/run-event-limits.ts"; +import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; +import { EXECUTOR_MAX_FRAME_BYTES } from "../executor/protocol.ts"; + +const MAX_PERSISTENCE_ITEMS = 1_000; +const MAX_TOOL_NAMES = 4_096; +const MAX_PROVIDER_BLOCKS = 100; +const MAX_PROVIDER_PARTS = 10_000; +const MAX_PERSISTENCE_PAYLOAD_BYTES = EXECUTOR_MAX_FRAME_BYTES - 2_048; +const encoder = new TextEncoder(); + +export const executorPersistenceOperations = Object.freeze( + { + publishParentRunEvents: "persistence.parent-run-events", + persistToolExposureCheckpoint: "persistence.tool-exposure-checkpoint", + persistProviderReplayCheckpoint: "persistence.provider-replay-checkpoint", + } as const, +); + +const getSequenceSchema = defineSchema((v) => + v.number().int().positive().max(Number.MAX_SAFE_INTEGER) +); +const getCapabilityRequestSchema = defineSchema((v) => + v.object({ + capabilityId: getExecutorDiscoveryIdSchema(), + sequence: getSequenceSchema(), + }).strict() +); + +/** Identifiers installed by the broker; authority and run ownership are never wire fields. */ +export const getExecutorPersistenceCapabilityIdsSchema = defineSchema((v) => + v.object({ + publishParentRunEvents: getExecutorDiscoveryIdSchema().optional(), + toolExposureCheckpoint: getExecutorDiscoveryIdSchema().optional(), + providerReplayCheckpoint: getExecutorDiscoveryIdSchema().optional(), + }).strict().refine((value) => { + const ids = Object.values(value).filter((id): id is string => id !== undefined); + return new Set(ids).size === ids.length; + }, "Managed persistence capability IDs must be distinct") +); +export type ExecutorPersistenceCapabilityIds = InferSchema< + ReturnType +>; + +export const getExecutorParentRunEventsRequestSchema = defineSchema((v) => + getCapabilityRequestSchema().extend({ + events: v.array( + getConversationRunEventSchema().refine((event) => + encoder.encode(JSON.stringify(event)).byteLength <= + MAX_CONVERSATION_RUN_EVENT_PAYLOAD_BYTES + ), + ).max(MAX_PERSISTENCE_ITEMS), + }).strict() +); + +export const getExecutorToolExposureCheckpointSchema = defineSchema((v) => + v.object({ + version: v.union([v.literal(1), v.literal(2)]), + loadedToolNames: v.array(v.string().min(1).max(256)).max(MAX_TOOL_NAMES), + }).strict() +); + +export const getExecutorToolExposureCheckpointRequestSchema = defineSchema((_v) => + getCapabilityRequestSchema().extend({ + checkpoint: getExecutorToolExposureCheckpointSchema(), + }).strict() +); + +export const getExecutorProviderReplayCheckpointSchema = defineSchema((v) => { + const provider = v.enum(["anthropic", "openai-responses"] as const); + return v.object({ + version: v.literal(1), + messageId: v.string().min(1).max(256), + provider, + providerBlocks: v.array( + v.object({ + type: v.literal("provider-block"), + provider, + block: v.record(v.string(), getJsonValueSchema()), + }).strict(), + ).min(1).max(MAX_PROVIDER_BLOCKS), + providerBlockPositions: v.array(v.number().int().nonnegative().max(MAX_PROVIDER_PARTS - 1)) + .min(1).max(MAX_PROVIDER_BLOCKS), + providerMessageBlockCounts: v.array(v.number().int().positive().max(MAX_PROVIDER_BLOCKS)) + .min(1).max(MAX_PROVIDER_BLOCKS).optional(), + totalPartCount: v.number().int().positive().max(MAX_PROVIDER_PARTS), + elapsedMs: v.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + emittedAt: v.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + }).strict(); +}); + +export const getExecutorProviderReplayCheckpointRequestSchema = defineSchema((_v) => + getCapabilityRequestSchema().extend({ + checkpoint: getExecutorProviderReplayCheckpointSchema(), + }).strict() +); + +export const getExecutorPersistenceAckSchema = defineSchema((v) => + v.object({ acknowledged: v.literal(true), sequence: getSequenceSchema() }).strict() +); + +export type ExecutorPersistenceAck = InferSchema< + ReturnType +>; + +/** Fixed diagnostics do not echo rejected events or opaque replay metadata. */ +export function parseExecutorPersistenceData(schema: Schema, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) throw new TypeError("Invalid managed persistence data"); + return result.data; +} + +/** Copy a validated persistence payload while enforcing the channel envelope budget. */ +export function executorPersistenceJson(value: unknown): JsonValue { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value); + } catch { + throw new TypeError("Invalid managed persistence data"); + } + if ( + encoded === undefined || + encoder.encode(encoded).byteLength > MAX_PERSISTENCE_PAYLOAD_BYTES + ) throw new TypeError("Invalid managed persistence data"); + const snapshot = snapshotBoundedJsonValue(JSON.parse(encoded)); + if (!snapshot.success) throw new TypeError("Invalid managed persistence data"); + return snapshot.value; +} diff --git a/src/agent/hosted/executor-runtime-entrypoint.ts b/src/agent/hosted/executor-runtime-entrypoint.ts new file mode 100644 index 0000000000..8bb4c5cc92 --- /dev/null +++ b/src/agent/hosted/executor-runtime-entrypoint.ts @@ -0,0 +1,156 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import process from "node:process"; +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import type { ExecutorChannel } from "../executor/channel.ts"; +import { + type ExecutorNodeBootstrapOptions, + readExecutorBootstrapConfiguration, + startExecutorNodeBootstrap, +} from "./executor-node-bootstrap.ts"; +import { createExecutorRuntimeInstallation } from "./executor-runtime-install.ts"; +import { + type ExecutorArtifactManifest, + getExecutorArtifactManifestSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; + +const ARTIFACT_MANIFEST = "/opt/veryfront/executor-artifact.json"; +const PROJECT_ROOT = "/opt/veryfront/project"; + +async function readFixedArtifact() { + const file = await open( + ARTIFACT_MANIFEST, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.uid !== 0 || (stat.mode & 0o022) !== 0 || stat.size > 64 * 1024) { + throw new Error("Invalid executor artifact"); + } + const buffer = new Uint8Array(64 * 1024 + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > 64 * 1024) throw new Error("Invalid executor artifact"); + const manifest = parseExecutorInstallation( + getExecutorArtifactManifestSchema(), + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset))), + ); + return { manifest, projectDir: PROJECT_ROOT }; + } catch { + throw new Error("Invalid executor artifact"); + } finally { + await file.close(); + } +} + +/** + * 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. + */ +export async function startExecutorRuntimeEntrypoint( + options: Pick & { + /** Trusted image/test boundary, never a channel field or environment path. */ + readArtifact?: () => Promise<{ manifest: ExecutorArtifactManifest; projectDir: string }>; + } = {}, +) { + const environment = options.environment ?? { get: (name) => process.env[name] }; + const { binding } = readExecutorBootstrapConfiguration(environment); + for ( + const contract of ["SchemaValidator", "Bundler", "ModuleLexer", "SkillDocumentParserProvider"] + ) { + if (!tryResolve(contract)) throw new TypeError("Executor runtime contracts are unavailable"); + } + const artifact = await (options.readArtifact ?? readFixedArtifact)(); + if (!isAbsolute(artifact.projectDir)) throw new TypeError("Invalid executor project root"); + const lifetime = new AbortController(); + const signal = AbortSignal.any([lifetime.signal, ...(options.signal ? [options.signal] : [])]); + 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 { 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, + environment, + operations: installation.operations, + signal, + }); + void bootstrap.ready.then(channel.resolve, channel.reject); + void bootstrap.ready.then(async (connected) => { + await connected.closed; + lifetime.abort(); + }).catch(() => lifetime.abort()); + let closing: Promise | undefined; + const close = (): Promise => { + if (!closing) { + closing = Promise.resolve().then(async () => { + bootstrap.close(); + await installation.close(); + const connected = await channel.promise.catch(() => undefined); + await connected?.settled; + }); + lifetime.abort(); + } + return closing; + }; + const settled = Promise.all([ + installation.settled, + channel.promise.then((connected) => connected.settled, () => {}), + ]).then(() => {}); + void settled.catch(() => {}); + return { address: bootstrap.address, ready: bootstrap.ready, close, settled }; + } catch (error) { + channel.reject(new Error("Executor startup failed")); + await installation.close(); + throw error; + } +} diff --git a/src/agent/hosted/executor-runtime-facades.test.ts b/src/agent/hosted/executor-runtime-facades.test.ts new file mode 100644 index 0000000000..a824a14cb2 --- /dev/null +++ b/src/agent/hosted/executor-runtime-facades.test.ts @@ -0,0 +1,179 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import type { ExecutorOperation } from "../executor/channel.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { createExecutorChannel } from "../executor/channel.ts"; +import type { ExecutorRuntimeInstall } from "./executor-runtime-install-schema.ts"; +import { createExecutorRuntimeFacades } from "./executor-runtime-facades.ts"; + +const binding = { + allocationId: "facade-allocation", + invocationId: "facade-invocation", + generation: 1, +}; +const modelId = "veryfront-cloud/openai/synthetic-model"; +function installation(): ExecutorRuntimeInstall { + return { + version: 1, + binding, + root: "project", + owner: { scopeKind: "global", serviceName: "veryfront-agent" }, + source: { type: "release", releaseId: "release-1" }, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 3, + models: [{ id: modelId, maxOutputTokens: 100, providerToolNames: [] }], + allowedToolNames: ["read"], + hostToolFacadeIds: ["host"], + remoteToolSourceIds: ["remote"], + execution: { kind: "ephemeral", projectId: null }, + }, + capabilities: { persistence: {} }, + }; +} +function channels(sources = ["host", "remote"]) { + const toBroker = new TransformStream(); + const toExecutor = new TransformStream(); + let executions = 0; + let writes = 0; + const operations = new Map([ + ["persistence.tool-exposure-checkpoint", { + mode: "unary", + handle(input) { + writes++; + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Invalid fixture request"); + } + return { acknowledged: true, sequence: input.sequence! }; + }, + }], + ["model.metadata", { + mode: "unary", + handle: () => [{ + id: modelId, + modelId: "synthetic-model", + provider: "openai", + specificationVersion: "v3", + }], + }], + ["tool.sources", { + mode: "stream", + async *handle(): AsyncGenerator { + for (const sourceId of sources) yield { type: "source", sourceId }; + yield { type: "complete" }; + }, + }], + ["tool.list", { + mode: "stream", + async *handle(): AsyncGenerator { + yield { + type: "tool", + definition: { + name: "read", + description: "Read a synthetic value", + parameters: { type: "object", properties: {} }, + }, + }; + yield { type: "complete" }; + }, + }], + ["tool.execute", { + mode: "stream", + async *handle() { + executions++; + yield { type: "result", result: "synthetic-result" }; + }, + }], + ]); + const broker = createExecutorChannel({ + binding, + operations, + transport: { readable: toBroker.readable, writable: toExecutor.writable }, + }); + const executor = createExecutorChannel({ + binding, + transport: { readable: toExecutor.readable, writable: toBroker.writable }, + }); + return { + broker, + executor, + get executions() { + return executions; + }, + get writes() { + return writes; + }, + async close() { + broker.close(); + executor.close(); + await Promise.all([broker.settled, executor.settled]); + }, + }; +} + +it("constructs model and host/remote tool facades only for the installed IDs", async () => { + const pair = channels(); + try { + const facades = await createExecutorRuntimeFacades({ + input: installation(), + channel: pair.executor, + signal: pair.executor.signal, + }); + assertEquals([...facades.hostTools.keys()], ["host"]); + assertEquals([...facades.remoteToolSources.keys()], ["remote"]); + const read = facades.hostTools.get("host")!.read!; + assertEquals(read.inputSchemaJson, { type: "object", properties: {} }); + assertEquals(await read.execute!({}, { toolCallId: "call-1" }), "synthetic-result"); + assertEquals(pair.executions, 1); + await facades.cleanup(); + await assertRejects(async () => { + await read.execute!({}, { toolCallId: "late" }); + }); + assertEquals(pair.executions, 1); + } finally { + await pair.close(); + } +}); + +it("rejects an incomplete or expanded tool source grant", async () => { + for (const sources of [["host"], ["host", "remote", "ungranted"]]) { + const pair = channels(sources); + try { + await assertRejects(() => + createExecutorRuntimeFacades({ + input: installation(), + channel: pair.executor, + signal: pair.executor.signal, + }) + ); + assertEquals(pair.executions, 0); + } finally { + await pair.close(); + } + } +}); + +it("revokes persistence facades on runtime cleanup without closing the shared channel", async () => { + const pair = channels(); + const input = installation(); + input.capabilities.persistence.toolExposureCheckpoint = "checkpoint"; + try { + const facades = await createExecutorRuntimeFacades({ + input, + channel: pair.executor, + signal: pair.executor.signal, + }); + const persist = facades.toolExposureCheckpoint!.persist; + await persist({ version: 1, loadedToolNames: [] }); + await facades.cleanup(); + await assertRejects(async () => { + await persist({ version: 1, loadedToolNames: [] }); + }); + assertEquals(pair.writes, 1); + assertEquals(pair.executor.signal.aborted, false); + } finally { + await pair.close(); + } +}); diff --git a/src/agent/hosted/executor-runtime-facades.ts b/src/agent/hosted/executor-runtime-facades.ts new file mode 100644 index 0000000000..1a778aa218 --- /dev/null +++ b/src/agent/hosted/executor-runtime-facades.ts @@ -0,0 +1,98 @@ +import type { ExecutorChannel } from "../executor/channel.ts"; +import type { HostToolSet } from "#veryfront/tool/host-tools.ts"; +import type { RemoteToolSource, ToolExecutionContext } from "#veryfront/tool/types.ts"; +import { revokeModelRuntimeResolver } from "../runtime/model-transport.ts"; +import type { ExecutorRuntimeFacades } from "./executor-runtime-prepare.ts"; +import type { ExecutorRuntimeInstall } from "./executor-runtime-install-schema.ts"; +import { createExecutorModelRuntimeResolver } from "./executor-model-bridge.ts"; +import { createExecutorRemoteToolSources } from "./executor-tool-remote-facade.ts"; +import { createExecutorPersistenceFacades } from "./executor-persistence-bridge.ts"; + +/** Executor-local capability views; cleanup revokes these views, never their shared channel. */ +export async function createExecutorRuntimeFacades(options: { + input: ExecutorRuntimeInstall; + channel: ExecutorChannel; + signal: AbortSignal; +}): Promise { + const { input, channel } = options; + const lifetime = new AbortController(); + const signal = AbortSignal.any([options.signal, channel.signal, lifetime.signal]); + const resolveModelRuntime = await createExecutorModelRuntimeResolver({ + channel, + allowedModelIds: new Set(input.grant.models.map((model) => model.id)), + signal, + }); + function cleanup(): Promise { + revokeModelRuntimeResolver(resolveModelRuntime); + lifetime.abort(); + return Promise.resolve(); + } + try { + signal.throwIfAborted(); + const hostIds = new Set(input.grant.hostToolFacadeIds); + const remoteIds = new Set(input.grant.remoteToolSourceIds); + const expected = new Set([...hostIds, ...remoteIds]); + if (expected.size !== hostIds.size + remoteIds.size) { + throw new TypeError("Ambiguous executor tool source grant"); + } + const sources = expected.size ? await createExecutorRemoteToolSources({ channel, signal }) : []; + if (sources.length !== expected.size || sources.some((source) => !expected.has(source.id))) { + throw new TypeError("Executor tool sources do not match installation"); + } + const hostTools = new Map(); + const remoteToolSources = new Map(); + for (const source of sources) { + if (remoteIds.has(source.id)) { + remoteToolSources.set(source.id, source); + continue; + } + const definitions = await source.listTools({ abortSignal: signal }); + const tools: HostToolSet = Object.create(null); + for (const definition of definitions) { + tools[definition.name] = { + id: definition.name, + title: definition.title, + description: definition.description, + inputSchemaJson: definition.parameters, + async execute(args: unknown, context?: ToolExecutionContext) { + signal.throwIfAborted(); + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw new TypeError("Invalid executor tool arguments"); + } + return await source.executeTool( + definition.name, + args as Record, + context, + ); + }, + }; + } + hostTools.set(source.id, tools); + } + const persistence = createExecutorPersistenceFacades({ + channel, + signal, + capabilityIds: input.capabilities.persistence, + initialToolExposureCheckpoint: input.checkpointState?.toolExposure, + initialProviderReplayCheckpoints: input.checkpointState?.providerReplay, + }); + const state = input.capabilities.projectSteering || input.capabilities.conversationUserText + ? (await import("./executor-state-bridge.ts")).createExecutorStateFacades({ + channel, + capabilityIds: { + projectSteering: input.capabilities.projectSteering, + conversationUserText: input.capabilities.conversationUserText, + }, + agentId: input.grant.agentId, + projectId: input.grant.execution.projectId, + branchId: input.grant.execution.branchId, + signal, + }) + : {}; + signal.throwIfAborted(); + return { resolveModelRuntime, hostTools, remoteToolSources, ...persistence, ...state, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} diff --git a/src/agent/hosted/executor-runtime-install-schema.ts b/src/agent/hosted/executor-runtime-install-schema.ts new file mode 100644 index 0000000000..732a584615 --- /dev/null +++ b/src/agent/hosted/executor-runtime-install-schema.ts @@ -0,0 +1,95 @@ +import type { InferSchema, Schema, SchemaValidator } from "#veryfront/extensions/schema/index.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import { getExecutorBindingSchema } from "../executor/protocol.ts"; +import { + getHostedExecutorOwnerSchema, + getHostedExecutorSourceSchema, +} from "./executor-session-schema.ts"; +import { getExecutorRuntimeGrantDataSchema } from "./executor-runtime-prepare-schema.ts"; +import { + getExecutorPersistenceCapabilityIdsSchema, + getExecutorProviderReplayCheckpointSchema, + getExecutorToolExposureCheckpointSchema, +} from "./executor-persistence-schema.ts"; +import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; + +function artifactShape(v: SchemaValidator) { + return { + version: v.literal(1), + owner: getHostedExecutorOwnerSchema(), + source: getHostedExecutorSourceSchema(), + root: v.literal("project"), + }; +} + +/** Image-builder-owned metadata outside the project payload. No caller-selected paths. */ +export const getExecutorArtifactManifestSchema = defineSchema((v) => + v.object(artifactShape(v)).strict() +); + +export const getExecutorRuntimeInstallSchema = defineSchema((v) => + v.object({ + ...artifactShape(v), + binding: getExecutorBindingSchema(), + grant: getExecutorRuntimeGrantDataSchema(), + capabilities: v.object({ + persistence: getExecutorPersistenceCapabilityIdsSchema(), + projectSteering: getExecutorDiscoveryIdSchema().optional(), + conversationUserText: getExecutorDiscoveryIdSchema().optional(), + }).strict(), + checkpointState: v.object({ + toolExposure: getExecutorToolExposureCheckpointSchema().optional(), + providerReplay: v.array(getExecutorProviderReplayCheckpointSchema()).max(100).optional(), + }).strict().optional(), + }).strict().refine(({ grant, capabilities, checkpointState }) => { + if (checkpointState?.toolExposure && !capabilities.persistence.toolExposureCheckpoint) { + return false; + } + if (checkpointState?.providerReplay && !capabilities.persistence.providerReplayCheckpoint) { + return false; + } + const execution = grant.execution; + if ( + (execution.projectId !== null || grant.requiredCapabilities?.includes("project-steering")) && + !capabilities.projectSteering + ) return false; + if ( + grant.requiredCapabilities?.includes("conversation-user-text") && + !capabilities.conversationUserText + ) return false; + if (execution.kind === "canonical") { + if ( + !capabilities.persistence.publishParentRunEvents || + !capabilities.persistence.toolExposureCheckpoint + ) return false; + if ( + execution.providerReplay === "required" && + !capabilities.persistence.providerReplayCheckpoint + ) return false; + } + return new Set(grant.models.map((model) => model.id)).size === grant.models.length && + grant.models.some((model) => model.id === grant.defaultModelId); + }, "Missing or ambiguous executor installation authority") +); + +export type ExecutorArtifactManifest = InferSchema< + ReturnType +>; +export type ExecutorRuntimeInstall = InferSchema< + ReturnType +>; + +/** Snapshot before parsing so installation never retains a caller's mutable objects. */ +export function parseExecutorInstallation(schema: Schema, input: unknown): T { + const snapshot = snapshotBoundedJsonValue(input); + if ( + !snapshot.success || + new TextEncoder().encode(JSON.stringify(snapshot.value)).byteLength > 64 * 1024 + ) { + throw new TypeError("Invalid executor installation"); + } + const result = schema.safeParse(snapshot.value); + if (!result.success) throw new TypeError("Invalid executor installation"); + return result.data; +} diff --git a/src/agent/hosted/executor-runtime-install.test.ts b/src/agent/hosted/executor-runtime-install.test.ts new file mode 100644 index 0000000000..f061996ed0 --- /dev/null +++ b/src/agent/hosted/executor-runtime-install.test.ts @@ -0,0 +1,231 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { createExecutorRuntimeInstallation } from "./executor-runtime-install.ts"; + +const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; +const artifact = { + version: 1, + owner: { scopeKind: "global", serviceName: "veryfront-agent" }, + source: { type: "release", releaseId: "release-1" }, + root: "project", +} as const; +const grant = { + agentId: "coder", + defaultModelId: "model", + maxSteps: 3, + models: [{ id: "model", maxOutputTokens: 100, providerToolNames: [] }], + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, +} as const; +function request(): JsonValue { + return JSON.parse( + JSON.stringify({ ...artifact, binding, grant, capabilities: { persistence: {} } }), + ); +} +function context(signal = new AbortController().signal): ExecutorOperationContext { + return { binding, signal, deadline: Date.now() + 10_000 }; +} +async function call( + operations: ReadonlyMap, + name: string, + input: JsonValue, + ctx = context(), +) { + const operation = operations.get(name); + if (operation?.mode !== "unary") throw new Error("Missing unary operation"); + return await operation.handle(input, ctx); +} +function runtime() { + const ended = Promise.withResolvers(); + let closes = 0; + return { + operations: new Map([ + ["discovery.describe", { mode: "unary", handle: () => ({ discovered: true }) }], + ["agent.describe", { mode: "unary", handle: () => ({ described: true }) }], + ["runtime.prepare", { mode: "unary", handle: () => ({ prepared: true }) }], + ["agent.stream", { + mode: "stream", + async *handle() { + yield { streamed: true }; + }, + }], + ]), + settled: ended.promise, + close: () => { + closes++; + ended.resolve(); + return Promise.resolve(); + }, + get closes() { + return closes; + }, + }; +} + +describe("executor runtime installation", () => { + it("registers fixed dispatch before bootstrap snapshots it, and imports only after authenticated installation", async () => { + let imports = 0; + const loaded = runtime(); + const owner = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => { + imports++; + return Promise.resolve(loaded); + }, + }); + const operations = new Map(owner.operations); + await assertRejects(() => call(operations, "discovery.describe", {})); + assertEquals(imports, 0); + assertEquals(await call(operations, "runtime.install", request()), { installed: true }); + assertEquals(imports, 1); + assertEquals(await call(operations, "discovery.describe", {}), { discovered: true }); + await assertRejects(() => call(operations, "runtime.install", request())); + await owner.close(); + await owner.settled; + assertEquals(loaded.closes, 1); + }); + + it("rejects changed owner, source, root, binding and undeclared fields before project imports", async () => { + let imports = 0; + const owner = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => { + imports++; + return Promise.resolve(runtime()); + }, + }); + for ( + const change of [ + { owner: { scopeKind: "global", serviceName: "other" } }, + { source: { type: "release", releaseId: "other" } }, + { root: "/arbitrary" }, + { binding: { ...binding, generation: 2 } }, + { providerToken: "synthetic-marker" }, + ] + ) { + await assertRejects(() => + call(owner.operations, "runtime.install", { + ...JSON.parse(JSON.stringify(request())), + ...change, + }) + ); + } + await assertRejects(() => + call(owner.operations, "runtime.install", request(), { + ...context(), + binding: { ...binding, invocationId: "other" }, + }) + ); + assertEquals(imports, 0); + await owner.close(); + }); + + it("requires canonical persistence and project capabilities before importing", async () => { + let imports = 0; + const owner = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => { + imports++; + return Promise.resolve(runtime()); + }, + }); + const input = JSON.parse(JSON.stringify(request())); + input.grant.execution = { + kind: "canonical", + projectId: "project-1", + conversationId: "conversation", + runId: "run", + messageId: "message", + providerReplay: "required", + }; + await assertRejects(() => call(owner.operations, "runtime.install", input)); + input.capabilities = { + persistence: { + publishParentRunEvents: "events", + toolExposureCheckpoint: "tools", + providerReplayCheckpoint: "replay", + }, + }; + await assertRejects(() => call(owner.operations, "runtime.install", input)); + assertEquals(imports, 0); + input.capabilities.projectSteering = "steering"; + assertEquals(await call(owner.operations, "runtime.install", input), { installed: true }); + await owner.close(); + }); + + it("revokes dispatch immediately while retaining late setup and cleanup until settled", async () => { + const pending = Promise.withResolvers>(); + const loaded = runtime(); + const owner = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => pending.promise, + }); + const installation = call(owner.operations, "runtime.install", request()); + await assertRejects(() => call(owner.operations, "runtime.install", request())); + const closing = owner.close(); + let settled = false; + void owner.settled.then(() => { + settled = true; + }); + await Promise.resolve(); + assertEquals(settled, false); + await assertRejects(() => call(owner.operations, "discovery.describe", {})); + pending.resolve(loaded); + await assertRejects(() => installation); + await closing; + await owner.settled; + assertEquals(loaded.closes, 1); + }); + + it("does not initialize when the installation request is already cancelled", async () => { + let imports = 0; + const owner = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => { + imports++; + return Promise.resolve(runtime()); + }, + }); + await assertRejects(() => + call(owner.operations, "runtime.install", request(), context(AbortSignal.abort())) + ); + assertEquals(imports, 0); + await owner.close(); + }); + + it("retains an outstanding dispatched operation even after runtime cleanup acknowledges closure", async () => { + const pending = Promise.withResolvers(); + const loaded = runtime(); + loaded.operations.set("discovery.describe", { mode: "unary", handle: () => pending.promise }); + const owner = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => Promise.resolve(loaded), + }); + await call(owner.operations, "runtime.install", request()); + const discovery = call(owner.operations, "discovery.describe", {}); + const closing = owner.close(); + let settled = false; + void owner.settled.then(() => { + settled = true; + }); + await loaded.settled; + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(settled, false); + pending.resolve({ discovered: true }); + await discovery; + await closing; + await owner.settled; + assertEquals(settled, true); + }); +}); diff --git a/src/agent/hosted/executor-runtime-install.ts b/src/agent/hosted/executor-runtime-install.ts new file mode 100644 index 0000000000..30274e4568 --- /dev/null +++ b/src/agent/hosted/executor-runtime-install.ts @@ -0,0 +1,178 @@ +import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; +import { sameHostedExecutorOwner } from "./executor-session-schema.ts"; +import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; +import { + type ExecutorArtifactManifest, + type ExecutorRuntimeInstall, + getExecutorArtifactManifestSchema, + getExecutorRuntimeInstallSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; + +export interface InstalledExecutorRuntime { + operations: ReadonlyMap; + close(): Promise; + readonly settled: Promise; +} + +const operationModes = { + "discovery.describe": "unary", + "agent.describe": "unary", + "runtime.prepare": "unary", + "agent.stream": "stream", +} as const; + +/** + * 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; +}) { + const binding = parseExecutorInstallation(getExecutorBindingSchema(), options.binding); + const artifact = parseExecutorInstallation(getExecutorArtifactManifestSchema(), options.artifact); + const lifetime = new AbortController(); + const settled = Promise.withResolvers(); + void settled.promise.catch(() => {}); + let phase: "empty" | "installing" | "installed" | "closed" = "empty"; + let setup: Promise | undefined; + let dispatch: ReadonlyMap | undefined; + let closing: Promise | undefined; + const tasks = new Set>(); + + function retainOperation() { + const task = Promise.withResolvers(); + tasks.add(task.promise); + return () => { + tasks.delete(task.promise); + task.resolve(); + }; + } + + function sameBinding(value: ExecutorBinding) { + return binding.allocationId === value.allocationId && binding.generation === value.generation && + binding.invocationId === value.invocationId; + } + function assertActive(context: ExecutorOperationContext) { + if ( + phase === "closed" || context.signal.aborted || context.deadline <= Date.now() || + !sameBinding(context.binding) + ) throw new Error("Executor installation unavailable"); + } + function close(): Promise { + if (closing) return closing; + phase = "closed"; + 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 () => { + 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")) { + 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); + return closing; + } + function abort() { + void close().catch(() => {}); + } + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); + + const operations = new Map(); + operations.set("runtime.install", { + mode: "unary", + async handle(value, context) { + assertActive(context); + if (phase !== "empty") throw new Error("Executor runtime already installed"); + const input = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), value); + if ( + !sameBinding(input.binding) || !sameHostedExecutorOwner(input.owner, artifact.owner) || + verifyHostedRuntimeSourceBinding(artifact.source, input.source) !== undefined || + input.root !== artifact.root + ) 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); + }); + try { + const loaded = await setup; + assertActive(context); + const registered = new Map(loaded.operations); + for (const [name, mode] of Object.entries(operationModes)) { + if (registered.get(name)?.mode !== mode) throw new Error("Incomplete executor runtime"); + } + dispatch = registered; + phase = "installed"; + return { installed: true }; + } catch { + void close().catch(() => {}); + throw new Error("Executor installation failed"); + } finally { + context.signal.removeEventListener("abort", abort); + } + }, + }); + for (const [name, mode] of Object.entries(operationModes)) { + if (mode === "unary") { + operations.set(name, { + mode, + async handle(value, context) { + assertActive(context); + const operation = dispatch?.get(name); + if (phase !== "installed" || operation?.mode !== "unary") { + throw new Error("Executor runtime not installed"); + } + const release = retainOperation(); + try { + return await operation.handle(value, { + ...context, + signal: AbortSignal.any([context.signal, lifetime.signal]), + }); + } finally { + release(); + } + }, + }); + } else {operations.set(name, { + mode, + async *handle(value, context) { + assertActive(context); + const operation = dispatch?.get(name); + if (phase !== "installed" || operation?.mode !== "stream") { + throw new Error("Executor runtime not installed"); + } + const release = retainOperation(); + try { + yield* operation.handle(value, { + ...context, + signal: AbortSignal.any([context.signal, lifetime.signal]), + }); + } finally { + release(); + } + }, + });} + } + return { operations, close, settled: settled.promise, signal: lifetime.signal }; +} diff --git a/src/agent/hosted/executor-state-bridge.test.ts b/src/agent/hosted/executor-state-bridge.test.ts new file mode 100644 index 0000000000..152e630749 --- /dev/null +++ b/src/agent/hosted/executor-state-bridge.test.ts @@ -0,0 +1,269 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import type { ExecutorRuntimeFacades } from "./executor-runtime-prepare.ts"; +import { createExecutorStateBroker, createExecutorStateFacades } from "./executor-state-bridge.ts"; +import { executorStateJson, executorStateOperations } from "./executor-state-schema.ts"; + +const binding = { + allocationId: "allocation-state", + generation: 3, + invocationId: "invocation-state", +}; +const scope = { agentId: "coder", projectId: "project-1", branchId: "branch-1" }; +const capabilityIds = { + projectSteering: "steering-capability", + conversationUserText: "text-capability", +}; +const definition = { id: "coder", name: "Coder", description: "Codes", instructions: "Work" }; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function pair(operations: ReadonlyMap) { + const forward = new TransformStream(); + const backward = new TransformStream(); + const executor = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations, + }); + return { + executor, + broker, + async close() { + executor.close(); + await broker.closed; + await Promise.all([executor.settled, broker.settled]); + }, + }; +} + +describe("executor state bridge", () => { + it("uses broker-owned scope and returns bounded steering and conversation state", async () => { + const seen: unknown[] = []; + const channels = pair(createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds, + prepareProjectSteering: async (input) => { + seen.push({ + kind: "prepare", + agentId: input.definition.id, + projectId: input.projectId, + branchId: input.branchId, + }); + return { + agent: input.definition, + initialProjectInstructions: "Project instructions", + initialSkills: [], + }; + }, + refreshProjectSteering: async () => { + seen.push({ kind: "refresh" }); + return "Refreshed instructions"; + }, + latestConversationUserText: async () => { + seen.push({ kind: "text" }); + return "Latest user text"; + }, + })); + try { + const facades = createExecutorStateFacades({ + channel: channels.executor, + ...scope, + capabilityIds, + }); + const compatible: Pick< + ExecutorRuntimeFacades, + "projectSteering" | "latestConversationUserText" + > = facades; + assertEquals(compatible, facades); + assertEquals( + await facades.projectSteering?.prepare({ + ...scope, + definition, + signal: new AbortController().signal, + }), + { + agent: definition, + initialProjectInstructions: "Project instructions", + initialSkills: [], + }, + ); + assertEquals( + await facades.projectSteering?.refresh(new AbortController().signal), + "Refreshed instructions", + ); + assertEquals( + await facades.latestConversationUserText?.(new AbortController().signal), + "Latest user text", + ); + assertEquals(seen, [ + { kind: "prepare", agentId: "coder", projectId: "project-1", branchId: "branch-1" }, + { kind: "refresh" }, + { kind: "text" }, + ]); + } finally { + await channels.close(); + } + }); + + it("rejects executor scope changes before channel dispatch", async () => { + let calls = 0; + const channels = pair(createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + prepareProjectSteering: async (input) => ({ agent: input.definition }), + refreshProjectSteering: async () => { + calls++; + return "refresh"; + }, + })); + try { + const facades = createExecutorStateFacades({ + channel: channels.executor, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + }); + await assertRejects(() => + facades.projectSteering!.prepare({ + definition, + projectId: "project-2", + branchId: "branch-1", + signal: new AbortController().signal, + }) + ); + await assertRejects(() => + facades.projectSteering!.prepare({ + definition: { ...definition, id: "other" }, + projectId: "project-1", + branchId: "branch-1", + signal: new AbortController().signal, + }) + ); + assertEquals(calls, 0); + } finally { + await channels.close(); + } + }); + + it("binds broker handlers and rejects wire authority fields", async () => { + let calls = 0; + const operations = createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds: { conversationUserText: capabilityIds.conversationUserText }, + latestConversationUserText: async () => { + calls++; + return null; + }, + }); + const operation = operations.get(executorStateOperations.latestConversationUserText); + if (operation?.mode !== "unary") throw new Error("missing operation"); + for ( + const value of [ + { capabilityId: capabilityIds.conversationUserText, authToken: "secret" }, + { capabilityId: capabilityIds.conversationUserText, projectId: "project-2" }, + ] + ) { + await assertRejects(() => + Promise.resolve( + operation.handle(executorStateJson(value), { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + }), + ) + ); + } + await assertRejects(() => + Promise.resolve( + operation.handle({ capabilityId: capabilityIds.conversationUserText }, { + binding: { ...binding, generation: 4 }, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + }), + ) + ); + assertEquals(calls, 0); + }); + + it("fails closed on incomplete capability handlers", () => { + assertThrows(() => + createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + }) + ); + }); + + it("forwards refresh cancellation and retains a noncooperative read through channel settlement", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const channels = pair(createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + prepareProjectSteering: async (input) => ({ agent: input.definition }), + refreshProjectSteering: async (signal) => { + entered.resolve(signal); + await release.promise; + return "late refresh"; + }, + })); + const owner = new AbortController(); + const call = new AbortController(); + const facades = createExecutorStateFacades({ + channel: channels.executor, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + signal: owner.signal, + }); + const pending = facades.projectSteering!.refresh(call.signal); + const observed = await entered.promise; + call.abort(); + await tick(); + assertEquals(observed.aborted, true); + + channels.executor.close(); + await channels.broker.closed; + await assertRejects(() => pending); + let settled = false; + void channels.broker.settled.then(() => settled = true); + await tick(); + assertEquals(settled, false); + release.resolve(); + await Promise.all([channels.executor.settled, channels.broker.settled]); + assertEquals(settled, true); + }); + + it("rejects provider transport authority in refreshed system data", async () => { + const channels = pair(createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + prepareProjectSteering: async (input) => ({ agent: input.definition }), + refreshProjectSteering: async () => [{ + role: "system", + content: "Synthetic", + providerOptions: { headers: { authorization: "secret" } }, + }], + })); + try { + const facades = createExecutorStateFacades({ + channel: channels.executor, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + }); + await assertRejects(() => facades.projectSteering!.refresh(new AbortController().signal)); + } finally { + await channels.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-state-bridge.ts b/src/agent/hosted/executor-state-bridge.ts new file mode 100644 index 0000000000..8b3f8852af --- /dev/null +++ b/src/agent/hosted/executor-state-bridge.ts @@ -0,0 +1,242 @@ +import type { AgentSystem } from "#veryfront/agent/types.ts"; +import type { RuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; +import type { HostedChatRuntimeProjectSteering } from "./chat-runtime-contract.ts"; +import type { ExecutorBinding } from "../executor/protocol.ts"; +import { getExecutorBindingSchema } from "../executor/protocol.ts"; +import type { + ExecutorChannel, + ExecutorOperation, + ExecutorOperationContext, +} from "../executor/channel.ts"; +import { + type ExecutorStateCapabilityIds, + executorStateJson, + executorStateOperations, + getExecutorAgentSystemSchema, + getExecutorConversationUserTextResultSchema, + getExecutorProjectSteeringPrepareRequestSchema, + getExecutorProjectSteeringResultSchema, + getExecutorStateCapabilityIdsSchema, + getExecutorStateReadRequestSchema, + parseExecutorStateData, +} from "./executor-state-schema.ts"; +import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; + +export type { ExecutorStateCapabilityIds } from "./executor-state-schema.ts"; + +type Scope = { agentId: string; projectId: string | null; branchId?: string | null }; +type ProjectSteeringPrepareInput = { + definition: RuntimeAgentMarkdownDefinition; + projectId: string | null; + branchId?: string | null; + signal: AbortSignal; +}; +export interface ExecutorStateFacades { + projectSteering?: { + prepare(input: ProjectSteeringPrepareInput): Promise< + HostedChatRuntimeProjectSteering + >; + refresh(signal: AbortSignal): Promise; + }; + latestConversationUserText?: (signal: AbortSignal) => Promise; +} + +function sameBinding(left: Readonly, right: Readonly) { + return left.allocationId === right.allocationId && left.generation === right.generation && + left.invocationId === right.invocationId; +} +function parseScope(value: Scope): Scope { + return Object.freeze({ + agentId: parseExecutorStateData(getExecutorDiscoveryIdSchema(), value.agentId), + projectId: value.projectId === null + ? null + : parseExecutorStateData(getExecutorDiscoveryIdSchema(), value.projectId), + ...(value.branchId === undefined ? {} : { + branchId: value.branchId === null + ? null + : parseExecutorStateData(getExecutorDiscoveryIdSchema(), value.branchId), + }), + }); +} +function authorize( + context: ExecutorOperationContext, + expectedBinding: ExecutorBinding, + actual: string, + expected: string, +) { + if (!sameBinding(context.binding, expectedBinding) || actual !== expected) { + throw new TypeError("Managed state operation is not authorized"); + } +} + +export function createExecutorStateBroker( + options: Scope & { + expectedBinding: ExecutorBinding; + capabilityIds: ExecutorStateCapabilityIds; + prepareProjectSteering?: ( + input: ProjectSteeringPrepareInput, + ) => Promise>; + refreshProjectSteering?: (signal: AbortSignal) => Promise | AgentSystem; + latestConversationUserText?: NonNullable; + }, +): ReadonlyMap { + const expectedBinding = Object.freeze(getExecutorBindingSchema().parse(options.expectedBinding)); + const scope = parseScope(options); + const capabilityIds = Object.freeze( + parseExecutorStateData(getExecutorStateCapabilityIdsSchema(), options.capabilityIds), + ); + const hasSteering = options.prepareProjectSteering !== undefined && + options.refreshProjectSteering !== undefined; + if ( + (capabilityIds.projectSteering !== undefined) !== hasSteering || + (!!options.prepareProjectSteering !== !!options.refreshProjectSteering) + ) throw new TypeError("Managed state capability configuration is incomplete"); + if ( + (capabilityIds.conversationUserText === undefined) !== + (options.latestConversationUserText === undefined) + ) throw new TypeError("Managed state capability configuration is incomplete"); + const operations = new Map(); + let steeringTail = Promise.resolve(undefined); + const scheduleSteering = ( + context: ExecutorOperationContext, + work: () => Promise | T, + ): Promise => { + const current = steeringTail.then(() => { + context.signal.throwIfAborted(); + return work(); + }); + steeringTail = current.catch(() => {}); + return current; + }; + if ( + capabilityIds.projectSteering && options.prepareProjectSteering && + options.refreshProjectSteering + ) { + const capabilityId = capabilityIds.projectSteering; + const prepare = options.prepareProjectSteering; + const refresh = options.refreshProjectSteering; + operations.set(executorStateOperations.prepareProjectSteering, { + mode: "unary", + async handle(value, context) { + const request = parseExecutorStateData( + getExecutorProjectSteeringPrepareRequestSchema(), + value, + ); + authorize(context, expectedBinding, request.capabilityId, capabilityId); + if (request.definition.id !== scope.agentId) { + throw new TypeError("Managed state agent is not authorized"); + } + const result = await scheduleSteering( + context, + () => prepare({ ...scope, definition: request.definition, signal: context.signal }), + ); + const parsed = parseExecutorStateData( + getExecutorProjectSteeringResultSchema(), + executorStateJson(result), + ); + if (parsed.agent.id !== scope.agentId) { + throw new TypeError("Managed state result is not authorized"); + } + return executorStateJson(parsed); + }, + }); + operations.set(executorStateOperations.refreshProjectSteering, { + mode: "unary", + async handle(value, context) { + const request = parseExecutorStateData(getExecutorStateReadRequestSchema(), value); + authorize(context, expectedBinding, request.capabilityId, capabilityId); + const result = await scheduleSteering(context, () => refresh(context.signal)); + return executorStateJson( + parseExecutorStateData(getExecutorAgentSystemSchema(), executorStateJson(result)), + ); + }, + }); + } + if (capabilityIds.conversationUserText && options.latestConversationUserText) { + const capabilityId = capabilityIds.conversationUserText; + const read = options.latestConversationUserText; + operations.set(executorStateOperations.latestConversationUserText, { + mode: "unary", + async handle(value, context) { + const request = parseExecutorStateData(getExecutorStateReadRequestSchema(), value); + authorize(context, expectedBinding, request.capabilityId, capabilityId); + const text = await read(context.signal); + return executorStateJson( + parseExecutorStateData(getExecutorConversationUserTextResultSchema(), { text }), + ); + }, + }); + } + return operations; +} + +export function createExecutorStateFacades( + options: Scope & { + channel: ExecutorChannel; + capabilityIds: ExecutorStateCapabilityIds; + signal?: AbortSignal; + }, +): ExecutorStateFacades { + const scope = parseScope(options); + const capabilityIds = Object.freeze( + parseExecutorStateData(getExecutorStateCapabilityIdsSchema(), options.capabilityIds), + ); + return { + ...(capabilityIds.projectSteering + ? { + projectSteering: { + async prepare(input: ProjectSteeringPrepareInput) { + if ( + scope.projectId !== input.projectId || scope.branchId !== input.branchId || + input.definition.id !== scope.agentId + ) { + throw new TypeError("Managed state scope cannot change"); + } + const request = executorStateJson({ + capabilityId: capabilityIds.projectSteering, + definition: input.definition, + }); + parseExecutorStateData(getExecutorProjectSteeringPrepareRequestSchema(), request); + const signal = options.signal + ? AbortSignal.any([options.signal, input.signal]) + : input.signal; + const result = await options.channel.request( + executorStateOperations.prepareProjectSteering, + request, + { signal }, + ); + const parsed = parseExecutorStateData(getExecutorProjectSteeringResultSchema(), result); + if (parsed.agent.id !== scope.agentId) { + throw new TypeError( + "Managed state result is not authorized", + ); + } + return parsed; + }, + async refresh(signal: AbortSignal) { + const combined = options.signal ? AbortSignal.any([options.signal, signal]) : signal; + const result = await options.channel.request( + executorStateOperations.refreshProjectSteering, + { capabilityId: capabilityIds.projectSteering! }, + { signal: combined }, + ); + return parseExecutorStateData(getExecutorAgentSystemSchema(), result); + }, + }, + } + : {}), + ...(capabilityIds.conversationUserText + ? { + latestConversationUserText: async (signal: AbortSignal) => { + const combined = options.signal ? AbortSignal.any([options.signal, signal]) : signal; + const result = await options.channel.request( + executorStateOperations.latestConversationUserText, + { capabilityId: capabilityIds.conversationUserText! }, + { signal: combined }, + ); + return parseExecutorStateData(getExecutorConversationUserTextResultSchema(), result).text; + }, + } + : {}), + }; +} diff --git a/src/agent/hosted/executor-state-schema.ts b/src/agent/hosted/executor-state-schema.ts new file mode 100644 index 0000000000..d4d0c1d681 --- /dev/null +++ b/src/agent/hosted/executor-state-schema.ts @@ -0,0 +1,149 @@ +import type { InferSchema, Schema } 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 { + getExecutorAgentDefinitionSchema, + getExecutorDiscoveryIdSchema, +} from "./executor-discovery-schema.ts"; +import { EXECUTOR_MAX_FRAME_BYTES } from "../executor/protocol.ts"; + +const MAX_STATE_PAYLOAD_BYTES = EXECUTOR_MAX_FRAME_BYTES - 2_048; +const MAX_SKILLS = 128; +const MAX_ARRAY_ITEMS = 3_000; +const MAX_TEXT_LENGTH = 1_048_576; +const encoder = new TextEncoder(); +const forbiddenProviderFields = new Set([ + "headers", + "authorization", + "auth", + "apikey", + "apitoken", + "authtoken", + "credential", + "credentials", + "baseurl", + "url", + "endpoint", + "fetch", + "signal", + "abortsignal", +]); + +export const executorStateOperations = Object.freeze( + { + prepareProjectSteering: "state.project-steering.prepare", + refreshProjectSteering: "state.project-steering.refresh", + latestConversationUserText: "state.conversation-user-text", + } as const, +); + +export const getExecutorStateCapabilityIdsSchema = defineSchema((v) => + v.object({ + projectSteering: getExecutorDiscoveryIdSchema().optional(), + conversationUserText: getExecutorDiscoveryIdSchema().optional(), + }).strict().refine((value) => { + const ids = Object.values(value).filter((id): id is string => id !== undefined); + return new Set(ids).size === ids.length; + }, "Managed state capability IDs must be distinct") +); +export type ExecutorStateCapabilityIds = InferSchema< + ReturnType +>; + +const getCapabilityRequestSchema = defineSchema((v) => + v.object({ capabilityId: getExecutorDiscoveryIdSchema() }).strict() +); +export const getExecutorProjectSteeringPrepareRequestSchema = defineSchema((_v) => + getCapabilityRequestSchema().extend({ definition: getExecutorAgentDefinitionSchema() }).strict() +); +export const getExecutorStateReadRequestSchema = getCapabilityRequestSchema; + +const getSkillSelectorPolicySchema = defineSchema((v) => + v.discriminatedUnion("kind", [ + v.object({ kind: v.literal("all-visible"), source: v.enum(["omitted", "true"] as const) }) + .strict(), + v.object({ kind: v.literal("none") }).strict(), + v.object({ + kind: v.literal("allowlist"), + entries: v.array(getExecutorDiscoveryIdSchema()).max(1_000), + }).strict(), + ]) +); +const getRuntimeSkillDefinitionSchema = defineSchema((v) => + v.object({ + id: getExecutorDiscoveryIdSchema(), + name: v.string().min(1).max(256), + displayName: v.string().max(256).optional(), + description: v.string().max(MAX_TEXT_LENGTH), + instructions: v.string().max(MAX_TEXT_LENGTH), + allowedTools: v.array(v.string().min(1).max(256)).max(100).optional(), + metadata: v.record(v.string().max(256), v.string().max(4_096)).optional(), + model: v.string().min(1).max(256).optional(), + thinking: v.union([v.literal(false), v.number().int().positive().max(1_000_000)]).optional(), + maxSteps: v.number().int().positive().max(1_000).optional(), + references: v.array(v.string().min(1).max(4_096)).max(MAX_ARRAY_ITEMS).optional(), + ownerAgentId: getExecutorDiscoveryIdSchema().optional(), + shortName: v.string().min(1).max(256).optional(), + sourcePath: v.string().min(1).max(4_096).optional(), + }).strict() +); +export const getExecutorProjectSteeringResultSchema = defineSchema((v) => + v.object({ + agent: getExecutorAgentDefinitionSchema(), + skillSelectorPolicy: getSkillSelectorPolicySchema().optional(), + environmentContext: v.string().max(MAX_TEXT_LENGTH).optional(), + initialProjectInstructions: v.string().max(MAX_TEXT_LENGTH).optional(), + initialSkills: v.array(getRuntimeSkillDefinitionSchema()).max(MAX_SKILLS).optional(), + }).strict() +); + +const getProviderOptionsSchema = defineSchema((v) => + v.record(v.string().max(256), getJsonValueSchema()).refine((value) => { + for (const field of Object.keys(value)) { + if (forbiddenProviderFields.has(field.replace(/[-_]/g, "").toLowerCase())) return false; + } + for (const bucket of Object.values(value)) { + if (bucket === null || typeof bucket !== "object" || Array.isArray(bucket)) continue; + for (const field of Object.keys(bucket)) { + if (forbiddenProviderFields.has(field.replace(/[-_]/g, "").toLowerCase())) return false; + } + } + return true; + }, "Managed state provider transport fields are forbidden") +); +export const getExecutorAgentSystemSchema = defineSchema((v) => + v.union([ + v.string().max(MAX_TEXT_LENGTH), + v.array( + v.object({ + role: v.literal("system"), + content: v.string().max(MAX_TEXT_LENGTH), + providerOptions: getProviderOptionsSchema().optional(), + }).strict(), + ).max(256), + ]) +); +export const getExecutorConversationUserTextResultSchema = defineSchema((v) => + v.object({ text: v.string().max(MAX_TEXT_LENGTH).nullable() }).strict() +); + +export function parseExecutorStateData(schema: Schema, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) throw new TypeError("Invalid managed state data"); + return result.data; +} + +export function executorStateJson(value: unknown): JsonValue { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value); + } catch { + throw new TypeError("Invalid managed state data"); + } + if (encoded === undefined || encoder.encode(encoded).byteLength > MAX_STATE_PAYLOAD_BYTES) { + throw new TypeError("Invalid managed state data"); + } + const snapshot = snapshotBoundedJsonValue(JSON.parse(encoded)); + if (!snapshot.success) throw new TypeError("Invalid managed state data"); + return snapshot.value; +} diff --git a/tests/fixtures/executor-runtime-process.ts b/tests/fixtures/executor-runtime-process.ts new file mode 100644 index 0000000000..a2c79bb8ab --- /dev/null +++ b/tests/fixtures/executor-runtime-process.ts @@ -0,0 +1,35 @@ +import "#veryfront/schemas/_test-setup.ts"; +import "#veryfront/skill/_test-setup.ts"; +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { register } from "#veryfront/extensions/contracts.ts"; +import { EsbuildBundler, EsModuleLexer } from "../../extensions/ext-bundler-esbuild/src/index.ts"; +import { startExecutorRuntimeEntrypoint } from "#veryfront/agent/hosted/executor-runtime-entrypoint.ts"; + +register("Bundler", new EsbuildBundler()); +register("ModuleLexer", new EsModuleLexer()); + +// Synthetic allocation key arrives on a private pipe; no broker environment or +// HTTP data is inherited by this executor process. +const executor = await startExecutorRuntimeEntrypoint({ + readKey: () => Promise.resolve(new Uint8Array(readFileSync(0))), + readArtifact: () => + Promise.resolve({ + manifest: { + version: 1, + root: "project", + owner: { scopeKind: "global", serviceName: "veryfront-agent" }, + source: { type: "release", releaseId: "synthetic-release" }, + }, + projectDir: process.argv[2]!, + }), +}); +process.stdout.write( + `${JSON.stringify({ ready: true, pid: process.pid, port: executor.address.port })}\n`, +); +try { + const channel = await executor.ready; + await channel.closed; +} finally { + await executor.close(); +} diff --git a/tests/integration/agent/executor-runtime-entrypoint.test.ts b/tests/integration/agent/executor-runtime-entrypoint.test.ts new file mode 100644 index 0000000000..e243475a73 --- /dev/null +++ b/tests/integration/agent/executor-runtime-entrypoint.test.ts @@ -0,0 +1,224 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { spawn } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +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 { startExecutorRuntimeEntrypoint } from "#veryfront/agent/hosted/executor-runtime-entrypoint.ts"; + +const root = new URL("../../../", import.meta.url); +const resolver = fileURLToPath(new URL("tests/node/resolver.mjs", root)); + +if (typeof Deno !== "undefined") { + it( + "runs the actual executor entrypoint in a separate Node process", + { timeout: 60_000 }, + async () => { + const child = spawn( + "node", + ["--import", resolver, "--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 timer = setTimeout(() => child.kill(), 55_000); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assertEquals(code, 0, output); + } finally { + clearTimeout(timer); + child.kill(); + } + }, + ); +} else { + it("rejects missing first-party runtime contracts before reading an allocation key", async () => { + let keyReads = 0; + let executor: Awaited> | undefined; + const values: Record = { + VERYFRONT_EXECUTOR_ALLOCATION_ID: randomUUID(), + VERYFRONT_EXECUTOR_INVOCATION_ID: randomUUID(), + VERYFRONT_EXECUTOR_GENERATION: "1", + VERYFRONT_EXECUTOR_ACTIVE_DEADLINE_SECONDS: "30", + VERYFRONT_EXECUTOR_HARD_DEADLINE_AT: String(Date.now() + 30_000), + PORT: "8081", + }; + try { + await assertRejects(async () => { + executor = await startExecutorRuntimeEntrypoint({ + environment: { get: (name) => values[name] }, + readArtifact: () => + Promise.resolve({ + manifest: { + version: 1, + root: "project", + owner: { scopeKind: "global", serviceName: "veryfront-agent" }, + source: { type: "release", releaseId: "synthetic-release" }, + }, + projectDir: "/synthetic-project", + }), + readKey: () => { + keyReads++; + return Promise.resolve(randomBytes(32)); + }, + }); + }); + assertEquals(keyReads, 0); + } finally { + 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, + ); + }); + 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: 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 input = { + 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: {} }, + }; + 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 }); + } + }); +} From db22c339da0e3b201c3e3dba6baf885576a411b6 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 14:43:38 +0200 Subject: [PATCH 076/194] fix(agent): preserve checkpoint sequencing and restore compatibility --- docs/guides/agent-service-runtime.md | 33 +++- .../hosted/executor-checkpoint-state.test.ts | 123 ++++++++++++++ src/agent/hosted/executor-checkpoint-state.ts | 156 ++++++++++++++++++ .../executor-persistence-bridge.test.ts | 73 +++++++- .../hosted/executor-persistence-bridge.ts | 48 +++--- .../hosted/executor-persistence-schema.ts | 2 +- .../hosted/executor-runtime-facades.test.ts | 57 ++++++- src/agent/hosted/executor-runtime-facades.ts | 8 +- .../hosted/executor-runtime-install-schema.ts | 18 +- 9 files changed, 469 insertions(+), 49 deletions(-) create mode 100644 src/agent/hosted/executor-checkpoint-state.test.ts create mode 100644 src/agent/hosted/executor-checkpoint-state.ts diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index ae92ab79bc..400f29c477 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -50,12 +50,11 @@ trace hooks, or application-error reporters. The framework-owned `veryfront serve` runtime owns this setup on shared and managed dedicated servers. -The service captures request accessors and routing primitives before project -modules load. Routing and CORS checks use those captured operations so later -changes to shared web prototypes cannot inspect the run-event or inference -credentials on an incoming request. Import the framework service runtime before -loading project modules. Custom host route handlers still receive the original -request and remain responsible for authentication and credential handling. +The standalone service shares a process with the Agent code it loads. Use it +for trusted code. Captured request accessors protect specific ingress operations; +they do not provide process isolation for request bodies or credentials. +Custom host route handlers receive the original request and remain responsible +for authentication and credential handling. Dispatch visits the host route table and matched path segments by index so a replaced array iterator cannot inject a handler before host authentication. CORS allowlist membership, response header writes, and route path parsing also @@ -418,6 +417,28 @@ A durable execution without authority bound to the expected run fails before provider dispatch. Token exchange failures are bounded, sanitized, and fail closed; callers must not retry by falling back to a user API token. +## Managed executor startup + +`startExecutorRuntimeEntrypoint` is available from +`veryfront/agent/executor-runtime`. It starts the executor side of a managed +broker/executor deployment on Node.js 22 or newer. Your trusted image launcher +registers the first-party schema validator, bundler, module lexer, and Skill +document 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. + +The broker owns HTTP authentication, credentials, model and tool authorization, +and durable persistence. Executor facades call these capabilities through the +authenticated channel. Closing a runtime revokes its facades; admission remains +held until the original work and cleanup settle. This entrypoint requires the +broker and isolation infrastructure to be configured separately. + ## Verify it worked Start the service entrypoint and call the run route directly. The default diff --git a/src/agent/hosted/executor-checkpoint-state.test.ts b/src/agent/hosted/executor-checkpoint-state.test.ts new file mode 100644 index 0000000000..05b19efc7d --- /dev/null +++ b/src/agent/hosted/executor-checkpoint-state.test.ts @@ -0,0 +1,123 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { ProviderReplayCheckpoint } from "../runtime/provider-replay.ts"; +import { createExecutorChannel } from "../executor/channel.ts"; +import { + copyExecutorReplayCheckpoints, + createExecutorCheckpointStateOperations, + executorInitialCheckpointsOperation, + readExecutorInitialCheckpoints, +} from "./executor-checkpoint-state.ts"; + +const binding = { + allocationId: "checkpoint-allocation", + invocationId: "checkpoint-invocation", + generation: 1, +}; +function checkpoint(id: string, size: number): ProviderReplayCheckpoint { + return { + version: 1, + messageId: id, + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "x".repeat(size) }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + elapsedMs: 12.5, + }; +} + +it("streams a checkpoint delivery larger than one channel frame and retains every anchor", async () => { + const checkpoints = Array.from( + { length: 6 }, + (_, index) => checkpoint(`message-${index}`, 200_000), + ); + const operations = createExecutorCheckpointStateOperations({ + expectedBinding: binding, + capabilityIds: { providerReplayCheckpoint: "replay" }, + initialProviderReplayCheckpoints: checkpoints, + }); + const toBroker = new TransformStream(); + const toExecutor = new TransformStream(); + const broker = createExecutorChannel({ + binding, + operations, + transport: { readable: toBroker.readable, writable: toExecutor.writable }, + }); + const executor = createExecutorChannel({ + binding, + transport: { readable: toExecutor.readable, writable: toBroker.writable }, + }); + try { + const restored = await readExecutorInitialCheckpoints({ + channel: executor, + capabilityIds: { providerReplayCheckpoint: "replay" }, + }); + assertEquals( + restored.initialProviderReplayCheckpoints?.map((value) => value.messageId), + checkpoints.map((value) => value.messageId), + ); + assertEquals( + copyExecutorReplayCheckpoints(restored.initialProviderReplayCheckpoints!).length, + 6, + ); + await assertRejects(() => + readExecutorInitialCheckpoints({ + channel: executor, + capabilityIds: { providerReplayCheckpoint: "replay" }, + }) + ); + } finally { + broker.close(); + executor.close(); + await Promise.all([broker.settled, executor.settled]); + } +}); + +it("rejects wrong binding and capability before consuming a snapshot grant", async () => { + const operation = createExecutorCheckpointStateOperations({ + expectedBinding: binding, + capabilityIds: { providerReplayCheckpoint: "replay" }, + initialProviderReplayCheckpoints: [checkpoint("message", 10)], + }).get(executorInitialCheckpointsOperation)!; + if (operation.mode !== "stream") throw new Error("Missing fixture stream"); + const handle = operation.handle; + const context = { binding, signal: new AbortController().signal, deadline: Date.now() + 10_000 }; + async function consume(value: JsonValue, bound = binding) { + const frames: JsonValue[] = []; + for await (const frame of handle(value, { ...context, binding: bound })) { + frames.push(frame); + } + return frames; + } + await assertRejects(() => consume({ kind: "provider-replay", capabilityId: "other" })); + await assertRejects(() => + consume({ kind: "provider-replay", capabilityId: "replay" }, { ...binding, generation: 2 }) + ); + await assertRejects(() => + consume({ kind: "provider-replay", capabilityId: "replay", runId: "other" }) + ); + assertEquals((await consume({ kind: "provider-replay", capabilityId: "replay" })).length, 2); + await assertRejects(() => consume({ kind: "provider-replay", capabilityId: "replay" })); +}); + +it("rejects duplicate anchors and missing grants instead of silently dropping state", () => { + assertThrows(() => copyExecutorReplayCheckpoints([checkpoint("same", 1), checkpoint("same", 2)])); + assertThrows(() => + copyExecutorReplayCheckpoints( + Array.from({ length: 101 }, (_, index) => checkpoint(String(index), 1)), + ) + ); + assertThrows(() => + createExecutorCheckpointStateOperations({ + expectedBinding: binding, + capabilityIds: {}, + initialProviderReplayCheckpoints: [checkpoint("message", 1)], + }) + ); +}); diff --git a/src/agent/hosted/executor-checkpoint-state.ts b/src/agent/hosted/executor-checkpoint-state.ts new file mode 100644 index 0000000000..3cf5d6fc74 --- /dev/null +++ b/src/agent/hosted/executor-checkpoint-state.ts @@ -0,0 +1,156 @@ +import type { ExecutorChannel, ExecutorOperation } from "../executor/channel.ts"; +import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; +import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; +import type { ToolExposureCheckpoint } from "../runtime/tool-exposure.ts"; +import { + parseServerResolvedProviderReplayCheckpoints, + type ProviderReplayCheckpoint, +} from "../runtime/provider-replay.ts"; +import { + type ExecutorPersistenceCapabilityIds, + executorPersistenceJson, + getExecutorPersistenceCapabilityIdsSchema, + getExecutorProviderReplayCheckpointSchema, + getExecutorToolExposureCheckpointSchema, + parseExecutorPersistenceData, +} from "./executor-persistence-schema.ts"; + +export const executorInitialCheckpointsOperation = "persistence.initial-checkpoints"; +const MAX_CHECKPOINTS = 100; +const getRequestSchema = defineSchema((v) => + v.object({ + capabilityId: v.string().min(1).max(128), + kind: v.enum(["tool-exposure", "provider-replay"] as const), + }).strict() +); +const getFrameSchema = defineSchema((v) => + v.discriminatedUnion("type", [ + v.object({ + type: v.literal("tool-exposure"), + checkpoint: getExecutorToolExposureCheckpointSchema(), + }).strict(), + v.object({ + type: v.literal("provider-replay"), + checkpoint: getExecutorProviderReplayCheckpointSchema(), + }).strict(), + v.object({ type: v.literal("complete") }).strict(), + ]) +); + +export interface ExecutorInitialCheckpointState { + initialToolExposureCheckpoint?: ToolExposureCheckpoint; + initialProviderReplayCheckpoints?: readonly ProviderReplayCheckpoint[]; +} + +/** Bound each checkpoint independently, matching the write path and canonical delivery count. */ +export function copyExecutorReplayCheckpoints( + value: readonly ProviderReplayCheckpoint[], +): ProviderReplayCheckpoint[] { + if (!Array.isArray(value) || value.length > MAX_CHECKPOINTS) { + throw new TypeError("Invalid executor checkpoint state"); + } + return parseServerResolvedProviderReplayCheckpoints( + value.map((checkpoint) => + parseExecutorPersistenceData( + getExecutorProviderReplayCheckpointSchema(), + executorPersistenceJson(checkpoint), + ) + ), + ); +} + +/** Each granted snapshot can be read once. State is never placed in runtime.install. */ +export function createExecutorCheckpointStateOperations( + options: ExecutorInitialCheckpointState & { + expectedBinding: ExecutorBinding; + capabilityIds: ExecutorPersistenceCapabilityIds; + }, +): ReadonlyMap { + const ids = getExecutorPersistenceCapabilityIdsSchema().parse(options.capabilityIds); + const binding = Object.freeze(getExecutorBindingSchema().parse(options.expectedBinding)); + if ( + options.initialToolExposureCheckpoint && !ids.toolExposureCheckpoint || + options.initialProviderReplayCheckpoints && !ids.providerReplayCheckpoint + ) throw new TypeError("Executor checkpoint state is not granted"); + const tool = options.initialToolExposureCheckpoint === undefined + ? undefined + : parseExecutorPersistenceData( + getExecutorToolExposureCheckpointSchema(), + executorPersistenceJson(options.initialToolExposureCheckpoint), + ); + const provider = copyExecutorReplayCheckpoints(options.initialProviderReplayCheckpoints ?? []); + const read = new Set(); + if (!ids.toolExposureCheckpoint && !ids.providerReplayCheckpoint) return new Map(); + return new Map([[executorInitialCheckpointsOperation, { + mode: "stream", + async *handle(value, context): AsyncGenerator { + const request = parseExecutorPersistenceData(getRequestSchema(), value); + context.signal.throwIfAborted(); + const expected = request.kind === "tool-exposure" + ? ids.toolExposureCheckpoint + : ids.providerReplayCheckpoint; + if ( + !expected || request.capabilityId !== expected || read.has(request.kind) || + context.binding.allocationId !== binding.allocationId || + context.binding.generation !== binding.generation || + context.binding.invocationId !== binding.invocationId || context.deadline <= Date.now() + ) throw new TypeError("Executor checkpoint state is not authorized"); + read.add(request.kind); + if (request.kind === "tool-exposure" && tool) { + yield executorPersistenceJson({ type: "tool-exposure", checkpoint: tool }); + } + if (request.kind === "provider-replay") { + for (const checkpoint of provider) { + context.signal.throwIfAborted(); + if (context.deadline <= Date.now()) { + throw new TypeError("Executor checkpoint state expired"); + } + yield executorPersistenceJson({ type: "provider-replay", checkpoint }); + } + } + context.signal.throwIfAborted(); + yield { type: "complete" }; + }, + }]]); +} + +export async function readExecutorInitialCheckpoints(options: { + channel: ExecutorChannel; + capabilityIds: ExecutorPersistenceCapabilityIds; + signal?: AbortSignal; +}): Promise { + const state: ExecutorInitialCheckpointState = {}; + for ( + const [kind, capabilityId] of [ + ["tool-exposure", options.capabilityIds.toolExposureCheckpoint], + ["provider-replay", options.capabilityIds.providerReplayCheckpoint], + ] as const + ) { + if (!capabilityId) continue; + let complete = false; + let count = 0; + const provider: ProviderReplayCheckpoint[] = []; + const frames = options.channel.stream(executorInitialCheckpointsOperation, { + capabilityId, + kind, + }, { signal: options.signal }); + for await (const value of frames) { + const frame = parseExecutorPersistenceData(getFrameSchema(), value); + if (complete) throw new TypeError("Invalid executor checkpoint completion"); + if (frame.type === "complete") { + complete = true; + continue; + } + if (frame.type !== kind || ++count > (kind === "tool-exposure" ? 1 : MAX_CHECKPOINTS)) { + throw new TypeError("Invalid executor checkpoint state"); + } + if (frame.type === "tool-exposure") state.initialToolExposureCheckpoint = frame.checkpoint; + else provider.push(frame.checkpoint); + } + if (!complete) throw new TypeError("Missing executor checkpoint completion"); + if (kind === "provider-replay") { + state.initialProviderReplayCheckpoints = copyExecutorReplayCheckpoints(provider); + } + } + return state; +} diff --git a/src/agent/hosted/executor-persistence-bridge.test.ts b/src/agent/hosted/executor-persistence-bridge.test.ts index eee12da04d..568c819ef2 100644 --- a/src/agent/hosted/executor-persistence-bridge.test.ts +++ b/src/agent/hosted/executor-persistence-bridge.test.ts @@ -24,11 +24,12 @@ const capabilityIds = { }; const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); -function pair(operations: ReadonlyMap) { +function pair(operations: ReadonlyMap, maxConcurrentCalls?: number) { const forward = new TransformStream(); const backward = new TransformStream(); const executor = createExecutorChannel({ binding, + maxConcurrentCalls, transport: { readable: backward.readable, writable: forward.writable }, }); const broker = createExecutorChannel({ @@ -48,6 +49,76 @@ function pair(operations: ReadonlyMap) { } describe("executor persistence bridge", () => { + it("keeps later writes usable after an unsent request hits channel admission", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const written: string[] = []; + const channels = pair( + createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { publishParentRunEvents: "events" }, + publishParentRunEvents: async (events) => { + written.push(events[0]!.type); + if (written.length === 1) { + entered.resolve(); + await release.promise; + } + }, + }), + 1, + ); + const facade = createExecutorPersistenceFacades({ + channel: channels.executor, + capabilityIds: { publishParentRunEvents: "events" }, + }); + try { + const first = facade.publishParentRunEvents!([{ type: "FIRST" }]); + await entered.promise; + await assertRejects(() => facade.publishParentRunEvents!([{ type: "UNSENT" }])); + release.resolve(); + await first; + await facade.publishParentRunEvents!([{ type: "THIRD" }]); + assertEquals(written, ["FIRST", "THIRD"]); + } finally { + release.resolve(); + await channels.close(); + } + }); + + it("persists canonical fractional checkpoint elapsed time", async () => { + let elapsed: number | undefined; + const channels = pair( + createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { providerReplayCheckpoint: "replay" }, + persistProviderReplayCheckpoint: async (checkpoint) => { + elapsed = checkpoint.elapsedMs; + }, + }), + ); + const facade = createExecutorPersistenceFacades({ + channel: channels.executor, + capabilityIds: { providerReplayCheckpoint: "replay" }, + }); + try { + await facade.providerReplayCheckpoint!.persist({ + version: 1, + messageId: "message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "synthetic" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + elapsedMs: 12.5, + }); + assertEquals(elapsed, 12.5); + } finally { + await channels.close(); + } + }); it("acknowledges actual persistence in one invocation order", async () => { const persisted: string[] = []; const channels = pair(createExecutorPersistenceBroker({ diff --git a/src/agent/hosted/executor-persistence-bridge.ts b/src/agent/hosted/executor-persistence-bridge.ts index 7eedb2ce75..0eadc34337 100644 --- a/src/agent/hosted/executor-persistence-bridge.ts +++ b/src/agent/hosted/executor-persistence-bridge.ts @@ -2,9 +2,11 @@ import type { ConversationRunEvent } from "#veryfront/agent/conversation/run-eve import type { Schema } from "#veryfront/extensions/schema/index.ts"; import type { ProviderReplayCheckpoint } from "#veryfront/agent/runtime/provider-replay.ts"; import { - parseProviderReplayCheckpoint, - parseServerResolvedProviderReplayCheckpoints, -} from "#veryfront/agent/runtime/provider-replay.ts"; + copyExecutorReplayCheckpoints, + createExecutorCheckpointStateOperations, + type ExecutorInitialCheckpointState, +} from "./executor-checkpoint-state.ts"; +import { parseProviderReplayCheckpoint } from "#veryfront/agent/runtime/provider-replay.ts"; import type { ToolExposureCheckpoint } from "#veryfront/agent/runtime/tool-exposure.ts"; import type { ExecutorBinding } from "../executor/protocol.ts"; import { getExecutorBindingSchema } from "../executor/protocol.ts"; @@ -73,17 +75,19 @@ async function awaitPersistence( * Install trusted persistence handlers. Run identity and credentials remain in * the supplied closures and are absent from every strict channel DTO. */ -export function createExecutorPersistenceBroker(options: { - expectedBinding: ExecutorBinding; - capabilityIds: ExecutorPersistenceCapabilityIds; - publishParentRunEvents?: NonNullable; - persistToolExposureCheckpoint?: NonNullable< - NonNullable["persist"] - >; - persistProviderReplayCheckpoint?: NonNullable< - NonNullable["persist"] - >; -}): ReadonlyMap { +export function createExecutorPersistenceBroker( + options: ExecutorInitialCheckpointState & { + expectedBinding: ExecutorBinding; + capabilityIds: ExecutorPersistenceCapabilityIds; + publishParentRunEvents?: NonNullable; + persistToolExposureCheckpoint?: NonNullable< + NonNullable["persist"] + >; + persistProviderReplayCheckpoint?: NonNullable< + NonNullable["persist"] + >; + }, +): ReadonlyMap { const expectedBinding = Object.freeze(getExecutorBindingSchema().parse(options.expectedBinding)); const capabilityIds = snapshotCapabilityIds(options.capabilityIds); const definitions = [ @@ -97,7 +101,7 @@ export function createExecutorPersistenceBroker(options: { } } - let expectedSequence = 1; + let lastAcceptedSequence = 0; let persistenceTail = Promise.resolve(); const authorize = ( capabilityId: string, @@ -107,9 +111,11 @@ export function createExecutorPersistenceBroker(options: { ) => { if ( !sameBinding(expectedBinding, context.binding) || capabilityId !== expectedCapabilityId || - sequence !== expectedSequence + sequence <= lastAcceptedSequence ) throw new TypeError("Managed persistence operation is not authorized"); - expectedSequence++; + // Unsent calls can consume a client sequence before channel admission. + // Gaps are safe; replays and out-of-order accepted writes are forbidden. + lastAcceptedSequence = sequence; }; const persist = async ( sequence: number, @@ -121,7 +127,9 @@ export function createExecutorPersistenceBroker(options: { await awaitPersistence(persistence, context.signal); return executorPersistenceJson({ acknowledged: true, sequence }); }; - const operations = new Map(); + const operations = new Map( + createExecutorCheckpointStateOperations({ ...options, expectedBinding, capabilityIds }), + ); if (capabilityIds.publishParentRunEvents && options.publishParentRunEvents) { const capabilityId = capabilityIds.publishParentRunEvents; const publish = options.publishParentRunEvents; @@ -199,9 +207,7 @@ export function createExecutorPersistenceFacades(options: { ); const initialProviderReplayCheckpoints = options.initialProviderReplayCheckpoints === undefined ? undefined - : parseServerResolvedProviderReplayCheckpoints( - executorPersistenceJson(options.initialProviderReplayCheckpoints), - ); + : copyExecutorReplayCheckpoints(options.initialProviderReplayCheckpoints); let sequence = 0; const request = async ( operation: string, diff --git a/src/agent/hosted/executor-persistence-schema.ts b/src/agent/hosted/executor-persistence-schema.ts index fb6cfaff4a..e4cba622e7 100644 --- a/src/agent/hosted/executor-persistence-schema.ts +++ b/src/agent/hosted/executor-persistence-schema.ts @@ -88,7 +88,7 @@ export const getExecutorProviderReplayCheckpointSchema = defineSchema((v) => { providerMessageBlockCounts: v.array(v.number().int().positive().max(MAX_PROVIDER_BLOCKS)) .min(1).max(MAX_PROVIDER_BLOCKS).optional(), totalPartCount: v.number().int().positive().max(MAX_PROVIDER_PARTS), - elapsedMs: v.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + elapsedMs: v.number().nonnegative().refine(Number.isFinite).optional(), emittedAt: v.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), }).strict(); }); diff --git a/src/agent/hosted/executor-runtime-facades.test.ts b/src/agent/hosted/executor-runtime-facades.test.ts index a824a14cb2..e654ad7d45 100644 --- a/src/agent/hosted/executor-runtime-facades.test.ts +++ b/src/agent/hosted/executor-runtime-facades.test.ts @@ -1,11 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; import type { ExecutorOperation } from "../executor/channel.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { createExecutorChannel } from "../executor/channel.ts"; import type { ExecutorRuntimeInstall } from "./executor-runtime-install-schema.ts"; import { createExecutorRuntimeFacades } from "./executor-runtime-facades.ts"; +import { createExecutorPersistenceBroker } from "./executor-persistence-bridge.ts"; +import type { ProviderReplayCheckpoint } from "../runtime/provider-replay.ts"; const binding = { allocationId: "facade-allocation", @@ -33,12 +35,21 @@ function installation(): ExecutorRuntimeInstall { capabilities: { persistence: {} }, }; } -function channels(sources = ["host", "remote"]) { +function channels( + sources = ["host", "remote"], + persistenceOperations: ReadonlyMap = new Map(), +) { const toBroker = new TransformStream(); const toExecutor = new TransformStream(); let executions = 0; let writes = 0; const operations = new Map([ + ["persistence.initial-checkpoints", { + mode: "stream", + async *handle() { + yield { type: "complete" }; + }, + }], ["persistence.tool-exposure-checkpoint", { mode: "unary", handle(input) { @@ -87,6 +98,7 @@ function channels(sources = ["host", "remote"]) { }, }], ]); + for (const [name, operation] of persistenceOperations) operations.set(name, operation); const broker = createExecutorChannel({ binding, operations, @@ -177,3 +189,44 @@ it("revokes persistence facades on runtime cleanup without closing the shared ch await pair.close(); } }); + +it("restores a durable replay checkpoint larger than the installation envelope", async () => { + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: "message-large", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "x".repeat(70_000) }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + elapsedMs: 12.5, + }; + const input = installation(); + input.capabilities.persistence.providerReplayCheckpoint = "replay"; + const pair = channels( + ["host", "remote"], + createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: input.capabilities.persistence, + persistProviderReplayCheckpoint: () => Promise.resolve(), + initialProviderReplayCheckpoints: [checkpoint], + }), + ); + try { + const facades = await createExecutorRuntimeFacades({ + input, + channel: pair.executor, + signal: pair.executor.signal, + }); + assertEquals(facades.providerReplayCheckpoint?.initial?.[0]?.messageId, "message-large"); + const data = facades.providerReplayCheckpoint?.initial?.[0]?.providerBlocks[0]?.block.data; + assert(typeof data === "string"); + assertEquals(data.length, 70_000); + await facades.cleanup(); + } finally { + await pair.close(); + } +}); diff --git a/src/agent/hosted/executor-runtime-facades.ts b/src/agent/hosted/executor-runtime-facades.ts index 1a778aa218..9603141825 100644 --- a/src/agent/hosted/executor-runtime-facades.ts +++ b/src/agent/hosted/executor-runtime-facades.ts @@ -7,6 +7,7 @@ import type { ExecutorRuntimeInstall } from "./executor-runtime-install-schema.t import { createExecutorModelRuntimeResolver } from "./executor-model-bridge.ts"; import { createExecutorRemoteToolSources } from "./executor-tool-remote-facade.ts"; import { createExecutorPersistenceFacades } from "./executor-persistence-bridge.ts"; +import { readExecutorInitialCheckpoints } from "./executor-checkpoint-state.ts"; /** Executor-local capability views; cleanup revokes these views, never their shared channel. */ export async function createExecutorRuntimeFacades(options: { @@ -70,11 +71,14 @@ export async function createExecutorRuntimeFacades(options: { hostTools.set(source.id, tools); } const persistence = createExecutorPersistenceFacades({ + ...await readExecutorInitialCheckpoints({ + channel, + signal, + capabilityIds: input.capabilities.persistence, + }), channel, signal, capabilityIds: input.capabilities.persistence, - initialToolExposureCheckpoint: input.checkpointState?.toolExposure, - initialProviderReplayCheckpoints: input.checkpointState?.providerReplay, }); const state = input.capabilities.projectSteering || input.capabilities.conversationUserText ? (await import("./executor-state-bridge.ts")).createExecutorStateFacades({ diff --git a/src/agent/hosted/executor-runtime-install-schema.ts b/src/agent/hosted/executor-runtime-install-schema.ts index 732a584615..879952d602 100644 --- a/src/agent/hosted/executor-runtime-install-schema.ts +++ b/src/agent/hosted/executor-runtime-install-schema.ts @@ -7,11 +7,7 @@ import { getHostedExecutorSourceSchema, } from "./executor-session-schema.ts"; import { getExecutorRuntimeGrantDataSchema } from "./executor-runtime-prepare-schema.ts"; -import { - getExecutorPersistenceCapabilityIdsSchema, - getExecutorProviderReplayCheckpointSchema, - getExecutorToolExposureCheckpointSchema, -} from "./executor-persistence-schema.ts"; +import { getExecutorPersistenceCapabilityIdsSchema } from "./executor-persistence-schema.ts"; import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; function artifactShape(v: SchemaValidator) { @@ -38,17 +34,7 @@ export const getExecutorRuntimeInstallSchema = defineSchema((v) => projectSteering: getExecutorDiscoveryIdSchema().optional(), conversationUserText: getExecutorDiscoveryIdSchema().optional(), }).strict(), - checkpointState: v.object({ - toolExposure: getExecutorToolExposureCheckpointSchema().optional(), - providerReplay: v.array(getExecutorProviderReplayCheckpointSchema()).max(100).optional(), - }).strict().optional(), - }).strict().refine(({ grant, capabilities, checkpointState }) => { - if (checkpointState?.toolExposure && !capabilities.persistence.toolExposureCheckpoint) { - return false; - } - if (checkpointState?.providerReplay && !capabilities.persistence.providerReplayCheckpoint) { - return false; - } + }).strict().refine(({ grant, capabilities }) => { const execution = grant.execution; if ( (execution.projectId !== null || grant.requiredCapabilities?.includes("project-steering")) && From 3823db29e3dc212222267ab860fcce7590abdbb4 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 15:15:47 +0200 Subject: [PATCH 077/194] fix(agent): recognize native Node behind packaged Deno compatibility --- scripts/test/coverage-node-executor.mjs | 1 - src/agent/hosted/executor-node-bootstrap.ts | 3 +- src/agent/hosted/executor-node-transport.ts | 3 +- .../agent/executor-node-bootstrap.test.ts | 29 +++++++++++++++++++ ...=> executor-runtime-entrypoint.fixture.ts} | 29 ++----------------- 5 files changed, 35 insertions(+), 30 deletions(-) rename tests/integration/agent/{executor-runtime-entrypoint.test.ts => executor-runtime-entrypoint.fixture.ts} (89%) diff --git a/scripts/test/coverage-node-executor.mjs b/scripts/test/coverage-node-executor.mjs index 38b276c13c..3e0e1339f8 100644 --- a/scripts/test/coverage-node-executor.mjs +++ b/scripts/test/coverage-node-executor.mjs @@ -18,7 +18,6 @@ const SOURCE_FILES = [ const TEST_FILES = [ "tests/integration/agent/executor-allocator-client.test.ts", "tests/integration/agent/executor-node-bootstrap.test.ts", - "tests/integration/agent/executor-runtime-entrypoint.test.ts", "tests/integration/agent/executor-node-transport.test.ts", "tests/integration/agent/service-header-boundary.test.ts", "tests/integration/agent/service-request-defaults.test.ts", diff --git a/src/agent/hosted/executor-node-bootstrap.ts b/src/agent/hosted/executor-node-bootstrap.ts index f178e77fb8..269bce3f2b 100644 --- a/src/agent/hosted/executor-node-bootstrap.ts +++ b/src/agent/hosted/executor-node-bootstrap.ts @@ -2,6 +2,7 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; import type { AddressInfo } from "node:net"; import process from "node:process"; +import { isNodeRuntime } from "#veryfront/platform/compat/runtime.ts"; import { tryResolve } from "#veryfront/extensions/contracts.ts"; import { createExecutorChannel, @@ -141,7 +142,7 @@ export async function startExecutorNodeBootstrap( options: ExecutorNodeBootstrapOptions, ): Promise { if ( - "Deno" in globalThis || "Bun" in globalThis || process.release.name !== "node" || + !isNodeRuntime() || process.release.name !== "node" || Number(process.versions.node.split(".")[0]) < 22 ) throw new Error("Executor bootstrap requires Node.js 22 or newer"); const startedAt = Date.now(); diff --git a/src/agent/hosted/executor-node-transport.ts b/src/agent/hosted/executor-node-transport.ts index 5106f3e1f5..bea4f2243e 100644 --- a/src/agent/hosted/executor-node-transport.ts +++ b/src/agent/hosted/executor-node-transport.ts @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { type AddressInfo, isIP, type Socket } from "node:net"; import process from "node:process"; +import { isNodeRuntime } from "#veryfront/platform/compat/runtime.ts"; import { connect, createServer, type TLSSocket } from "node:tls"; import type { ExecutorByteTransport } from "#veryfront/agent/executor/channel.ts"; import type { ExecutorBinding } from "#veryfront/agent/executor/protocol.ts"; @@ -57,7 +58,7 @@ function validateOptions( listen = false, ) { if ( - "Deno" in globalThis || "Bun" in globalThis || process.release.name !== "node" || + !isNodeRuntime() || process.release.name !== "node" || Number(process.versions.node.split(".")[0]) < 22 ) { throw new Error("Executor TLS transport requires Node.js 22 or newer"); diff --git a/tests/integration/agent/executor-node-bootstrap.test.ts b/tests/integration/agent/executor-node-bootstrap.test.ts index 6bbba33b7c..bd62081c9a 100644 --- a/tests/integration/agent/executor-node-bootstrap.test.ts +++ b/tests/integration/agent/executor-node-bootstrap.test.ts @@ -17,6 +17,7 @@ import { connectExecutorTransport } from "#veryfront/agent/hosted/executor-node- import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { registerExecutorRuntimeEntrypointTests } from "./executor-runtime-entrypoint.fixture.ts"; const binding = { allocationId: "00000000-0000-4000-8000-000000000001", @@ -84,6 +85,34 @@ if (typeof Deno !== "undefined") { ); } else { describe("fixed Node executor bootstrap", () => { + registerExecutorRuntimeEntrypointTests(); + it("accepts native Node when a Deno compatibility namespace is present", async () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "Deno"); + const key = randomBytes(32); + let bootstrap: Awaited> | undefined; + let caller: Awaited> | undefined; + Object.defineProperty(globalThis, "Deno", { + configurable: true, + value: { version: { deno: "compatibility" } }, + }); + try { + bootstrap = await startExecutorNodeBootstrap({ + operations, + environment: environment(), + readKey: () => Promise.resolve(new Uint8Array(key)), + }); + caller = await connectCaller(bootstrap.address.port, key); + await caller.ready; + assertEquals(await caller.request("echo", "packaged-node"), "packaged-node"); + } finally { + if (original) Object.defineProperty(globalThis, "Deno", original); + else Reflect.deleteProperty(globalThis, "Deno"); + caller?.close(); + bootstrap?.close(); + await caller?.settled; + await bootstrap?.ready.then((channel) => channel.settled, () => {}); + } + }); it("rejects missing and noncanonical bootstrap values before reading a key", async () => { let reads = 0; const invalid: Record[] = Object.keys(values).map((name) => ({ diff --git a/tests/integration/agent/executor-runtime-entrypoint.test.ts b/tests/integration/agent/executor-runtime-entrypoint.fixture.ts similarity index 89% rename from tests/integration/agent/executor-runtime-entrypoint.test.ts rename to tests/integration/agent/executor-runtime-entrypoint.fixture.ts index e243475a73..8ac94c8923 100644 --- a/tests/integration/agent/executor-runtime-entrypoint.test.ts +++ b/tests/integration/agent/executor-runtime-entrypoint.fixture.ts @@ -18,33 +18,8 @@ import { startExecutorRuntimeEntrypoint } from "#veryfront/agent/hosted/executor const root = new URL("../../../", import.meta.url); const resolver = fileURLToPath(new URL("tests/node/resolver.mjs", root)); -if (typeof Deno !== "undefined") { - it( - "runs the actual executor entrypoint in a separate Node process", - { timeout: 60_000 }, - async () => { - const child = spawn( - "node", - ["--import", resolver, "--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 timer = setTimeout(() => child.kill(), 55_000); - try { - const code = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("close", resolve); - }); - assertEquals(code, 0, output); - } finally { - clearTimeout(timer); - child.kill(); - } - }, - ); -} else { +/** Register beside the fixed-port bootstrap tests so Deno file parallelism cannot race port 8081. */ +export function registerExecutorRuntimeEntrypointTests(): void { it("rejects missing first-party runtime contracts before reading an allocation key", async () => { let keyReads = 0; let executor: Awaited> | undefined; From 46d8fcb7972e8d8dd9cc5e83ca109e1813448172 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 16:15:21 +0200 Subject: [PATCH 078/194] feat(agent): compose managed broker ingress and durable run ownership --- deno.json | 1 + docs/api-reference/veryfront/agent.md | 62 +++ docs/guides/agent-service-runtime.md | 13 +- src/agent/hosted/executor-allocator-client.ts | 3 +- .../hosted/executor-runtime-contracts.test.ts | 70 +++ .../hosted/executor-runtime-contracts.ts | 55 ++ .../hosted/executor-runtime-entrypoint.ts | 17 +- .../hosted/executor-runtime-prepare-schema.ts | 37 ++ .../executor-runtime-settlement.test.ts | 22 + .../hosted/executor-runtime-settlement.ts | 7 + .../hosted/executor-session-pool.test.ts | 3 + src/agent/hosted/executor-session.test.ts | 57 +- src/agent/hosted/executor-session.ts | 13 + .../hosted/managed-broker-persistence.test.ts | 188 +++++++ .../hosted/managed-broker-persistence.ts | 150 ++++++ .../managed-broker-project-state.test.ts | 149 ++++++ .../hosted/managed-broker-project-state.ts | 140 +++++ .../hosted/managed-executor-broker.test.ts | 489 ++++++++++++++++++ src/agent/hosted/managed-executor-broker.ts | 413 +++++++++++++++ src/agent/service/broker-ingress.test.ts | 293 +++++++++++ src/agent/service/broker-ingress.ts | 306 +++++++++++ .../service/managed-broker-handler.test.ts | 351 +++++++++++++ src/agent/service/managed-broker-handler.ts | 314 +++++++++++ src/agent/service/managed-broker.ts | 28 + tests/fixtures/executor-runtime-process.ts | 12 +- .../agent/executor-allocator-client.test.ts | 17 + .../agent/managed-broker-imports.test.ts | 71 +++ 27 files changed, 3262 insertions(+), 19 deletions(-) create mode 100644 src/agent/hosted/executor-runtime-contracts.test.ts create mode 100644 src/agent/hosted/executor-runtime-contracts.ts create mode 100644 src/agent/hosted/executor-runtime-settlement.test.ts create mode 100644 src/agent/hosted/executor-runtime-settlement.ts create mode 100644 src/agent/hosted/managed-broker-persistence.test.ts create mode 100644 src/agent/hosted/managed-broker-persistence.ts create mode 100644 src/agent/hosted/managed-broker-project-state.test.ts create mode 100644 src/agent/hosted/managed-broker-project-state.ts create mode 100644 src/agent/hosted/managed-executor-broker.test.ts create mode 100644 src/agent/hosted/managed-executor-broker.ts create mode 100644 src/agent/service/broker-ingress.test.ts create mode 100644 src/agent/service/broker-ingress.ts create mode 100644 src/agent/service/managed-broker-handler.test.ts create mode 100644 src/agent/service/managed-broker-handler.ts create mode 100644 src/agent/service/managed-broker.ts create mode 100644 tests/integration/agent/managed-broker-imports.test.ts diff --git a/deno.json b/deno.json index 95e17e5c01..f696668f24 100644 --- a/deno.json +++ b/deno.json @@ -100,6 +100,7 @@ "./mdx": "./src/mdx/index.ts", "./agent": "./src/agent/index.ts", "./agent/executor-runtime": "./src/agent/hosted/executor-runtime-entrypoint.ts", + "./agent/managed-broker": "./src/agent/service/managed-broker.ts", "./agent/identity": "./src/agent/identity-contracts.ts", "./eval": "./src/eval/index.ts", "./metrics": "./src/metrics/public.ts", diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 1f9e8e492c..084ca7275c 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -1921,6 +1921,22 @@ Input delivered to a hosted agent-service detached execution callback. These import paths group focused functionality under this module. Each is a separate barrel; import only what you need. +### `veryfront/agent/executor-runtime` + +```ts +import { + initializeExecutorRuntimeContracts, + startExecutorRuntimeEntrypoint, +} from "veryfront/agent/executor-runtime"; +``` + +#### 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) | + ### `veryfront/agent/identity` ```ts @@ -1964,3 +1980,49 @@ import { | `ProjectAgentRunSnapshot` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/identity-contracts.ts) | | `SourceProjectAgentExecutionIdentity` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/identity-contracts.ts) | | `SourceProjectAgentRunSnapshot` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/identity-contracts.ts) | + +### `veryfront/agent/managed-broker` + +Managed broker composition without project runtime or application imports. + +```ts +import { + connectExecutorTransport, + createHostedExecutorAllocatorClient, + createManagedBrokerHandler, +} from "veryfront/agent/managed-broker"; +``` + +#### Functions + +| Name | Description | Source | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `connectExecutorTransport` | Node-only TLS 1.3 PSK. No certificate fallback, session reuse, or reconnect. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | +| `createHostedExecutorAllocatorClient` | Trusted broker client for the operator's dedicated TLS endpoint. Each call reads the rotated Pod-bound token. No redirects, automatic POST retries, arbitrary headers, application credentials, or ambient gateway fallback. The returned promise retains raw token-read and socket ownership; the session supplies prompt cancellation notification separately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-allocator-client.ts) | +| `createManagedBrokerHandler` | Handle signed run invocations with configured detached or request-owned SSE responses. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `createManagedBrokerPersistence` | Create exact-run API persistence callbacks while retaining credentials in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | +| `createManagedExecutorBroker` | Compose an executor pool with authenticated installation and operation gates. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `parseBrokerRuntimeAgentIngress` | Read and verify a signed invocation once before constructing executor-safe data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | + +#### Classes + +| Name | Description | Source | +| -------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `BrokerIngressError` | Fixed ingress failures contain no body, signature, credential, or verifier diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | + +#### Types + +| Name | Description | Source | +| ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | +| `ManagedBrokerOutput` | Broker-owned output persistence for a detached run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 400f29c477..fec73ed660 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -422,8 +422,9 @@ closed; callers must not retry by falling back to a user API token. `startExecutorRuntimeEntrypoint` is available from `veryfront/agent/executor-runtime`. It starts the executor side of a managed broker/executor deployment on Node.js 22 or newer. Your trusted image launcher -registers the first-party schema validator, bundler, module lexer, and Skill -document parser, then supplies the Operator allocation environment and image +calls `initializeExecutorRuntimeContracts()` from the same export to initialize +the first-party schema validator, bundler, module lexer, and Skill document +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 @@ -439,6 +440,14 @@ authenticated channel. Closing a runtime revokes its facades; admission remains held until the original work and cleanup settle. This entrypoint requires the broker and isolation infrastructure to be configured separately. +Use `veryfront/agent/managed-broker` for the broker composition and signed +control-plane HTTP adapter. The broker installs invocation grants, describes +the selected agent, and prepares the executor before accepting a run. It keeps +model and tool execution unavailable during preparation. Configure detached +202 responses or request-owned SSE responses in trusted service configuration. +Detached runs require output persistence callbacks; their finalization remains +part of the session's owned work until all writes settle. + ## Verify it worked Start the service entrypoint and call the run route directly. The default diff --git a/src/agent/hosted/executor-allocator-client.ts b/src/agent/hosted/executor-allocator-client.ts index faf627553d..99fba747f5 100644 --- a/src/agent/hosted/executor-allocator-client.ts +++ b/src/agent/hosted/executor-allocator-client.ts @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import { lookup } from "node:dns/promises"; import { request as httpsRequest } from "node:https"; import process from "node:process"; +import { isNodeRuntime } from "#veryfront/platform/compat/runtime.ts"; import type { HostedExecutorAllocatorClient } from "#veryfront/agent/hosted/executor-session.ts"; import { getHostedExecutorAllocationRequestSchema, @@ -52,7 +53,7 @@ export function createHostedExecutorAllocatorClient(options: { timeoutMs?: number; }): HostedExecutorAllocatorClient { if ( - "Deno" in globalThis || "Bun" in globalThis || process.release.name !== "node" || + !isNodeRuntime() || process.release.name !== "node" || Number(process.versions.node.split(".")[0]) < 22 ) { throw new Error("Executor allocator client requires Node.js 22 or newer"); diff --git a/src/agent/hosted/executor-runtime-contracts.test.ts b/src/agent/hosted/executor-runtime-contracts.test.ts new file mode 100644 index 0000000000..16d3402256 --- /dev/null +++ b/src/agent/hosted/executor-runtime-contracts.test.ts @@ -0,0 +1,70 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import { initializeExecutorRuntimeContracts } from "./executor-runtime-contracts.ts"; + +it("initializes executor runtime contracts once and preserves their trusted generations", async () => { + await Promise.all([ + initializeExecutorRuntimeContracts(), + initializeExecutorRuntimeContracts(), + ]); + const first = [ + tryResolve("SchemaValidator"), + tryResolve("Bundler"), + tryResolve("ModuleLexer"), + tryResolve("SkillDocumentParserProvider"), + ]; + assertEquals(first.every((contract) => contract !== undefined), true); + await initializeExecutorRuntimeContracts(); + assertEquals([ + tryResolve("SchemaValidator"), + tryResolve("Bundler"), + tryResolve("ModuleLexer"), + tryResolve("SkillDocumentParserProvider"), + ], first); +}); + +it("keeps project runtime modules outside the contract initializer graph", async () => { + const root = new URL("../../../", import.meta.url); + const command = new Deno.Command(Deno.execPath(), { + cwd: root, + args: [ + "info", + "--frozen", + "--json", + "src/agent/hosted/executor-runtime-contracts.ts", + ], + }); + const output = await command.output(); + assertEquals(output.code, 0, new TextDecoder().decode(output.stderr)); + const graph = JSON.parse(new TextDecoder().decode(output.stdout)) as { + roots: string[]; + modules: Array<{ specifier: string; dependencies?: Array<{ code?: { specifier: string } }> }>; + }; + const modules = new Map(graph.modules.map((module) => [module.specifier, module])); + const visited = new Set(); + const pending = [...graph.roots]; + while (pending.length) { + const specifier = pending.shift()!; + if (visited.has(specifier)) continue; + visited.add(specifier); + for (const dependency of modules.get(specifier)?.dependencies ?? []) { + if (dependency.code) pending.push(dependency.code.specifier); + } + } + const forbidden = [ + "/src/config/loader.ts", + "/src/agent/project/agent-runtime.ts", + "/src/agent/hosted/cloud-agent-config.ts", + "/src/agent/hosted/executor-discovery-node.ts", + "/src/agent/hosted/default-chat-runtime.ts", + ]; + assertEquals( + [...visited].filter((specifier) => forbidden.some((path) => specifier.endsWith(path))), + [], + ); + assert( + [...visited].some((specifier) => specifier.endsWith("/src/extensions/bundler/defaults.ts")), + ); +}); diff --git a/src/agent/hosted/executor-runtime-contracts.ts b/src/agent/hosted/executor-runtime-contracts.ts new file mode 100644 index 0000000000..ab34e2e9d6 --- /dev/null +++ b/src/agent/hosted/executor-runtime-contracts.ts @@ -0,0 +1,55 @@ +import { ensureBuiltinSchemaValidator } from "#veryfront/extensions/builtin-schema-validator.ts"; +import { ensureDefaultBundlerContracts } from "#veryfront/extensions/bundler/defaults.ts"; +import type { Bundler } from "#veryfront/extensions/bundler/bundler.ts"; +import type { ModuleLexer } from "#veryfront/extensions/bundler/module-lexer.ts"; +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import { + ensureDefaultSkillDocumentParserContract, +} from "#veryfront/extensions/parser/skill-defaults.ts"; +import { + type SkillDocumentParserProvider, + SkillDocumentParserProviderName, +} from "#veryfront/extensions/parser/skill-document-parser.ts"; +import type { SchemaValidator } from "#veryfront/extensions/schema/index.ts"; + +let initialization: Promise | undefined; + +function assertRuntimeContracts(): void { + const schema = tryResolve("SchemaValidator"); + const bundler = tryResolve("Bundler"); + const lexer = tryResolve("ModuleLexer"); + const skillParser = tryResolve(SkillDocumentParserProviderName); + if ( + !schema || typeof schema.string !== "function" || typeof schema.object !== "function" || + !bundler || typeof bundler.bundle !== "function" || typeof bundler.transform !== "function" || + !lexer || typeof lexer.parse !== "function" || + !skillParser || typeof skillParser.parseFrontmatter !== "function" + ) throw new TypeError("Executor runtime contracts are unavailable"); +} + +/** + * Install the fixed first-party contracts required before executor project imports. + * Concurrent and repeated startup preserves any already-registered trusted generation. + */ +export async function initializeExecutorRuntimeContracts(): Promise { + try { + assertRuntimeContracts(); + return; + } catch { + // Missing contracts are initialized below; malformed registered contracts + // remain authoritative and fail the final validation instead of being replaced. + } + initialization ??= (async () => { + ensureBuiltinSchemaValidator(); + await Promise.all([ + ensureDefaultSkillDocumentParserContract(), + // Uses the fixed ext-bundler-esbuild first-party import and rechecks the + // registry after asynchronous module loading before registering. + ensureDefaultBundlerContracts(), + ]); + assertRuntimeContracts(); + })().finally(() => { + initialization = undefined; + }); + await initialization; +} diff --git a/src/agent/hosted/executor-runtime-entrypoint.ts b/src/agent/hosted/executor-runtime-entrypoint.ts index 8bb4c5cc92..de4fdbd935 100644 --- a/src/agent/hosted/executor-runtime-entrypoint.ts +++ b/src/agent/hosted/executor-runtime-entrypoint.ts @@ -10,6 +10,8 @@ import { startExecutorNodeBootstrap, } from "./executor-node-bootstrap.ts"; import { createExecutorRuntimeInstallation } from "./executor-runtime-install.ts"; +import { awaitExecutorCleanup } from "./executor-runtime-settlement.ts"; +export { initializeExecutorRuntimeContracts } from "./executor-runtime-contracts.ts"; import { type ExecutorArtifactManifest, getExecutorArtifactManifestSchema, @@ -132,20 +134,19 @@ export async function startExecutorRuntimeEntrypoint( let closing: Promise | undefined; const close = (): Promise => { if (!closing) { - closing = Promise.resolve().then(async () => { - bootstrap.close(); - await installation.close(); - const connected = await channel.promise.catch(() => undefined); - await connected?.settled; - }); + closing = awaitExecutorCleanup([ + Promise.resolve().then(() => bootstrap.close()), + Promise.resolve().then(() => installation.close()), + channel.promise.then((connected) => connected.settled, () => {}), + ]); lifetime.abort(); } return closing; }; - const settled = Promise.all([ + const settled = awaitExecutorCleanup([ installation.settled, channel.promise.then((connected) => connected.settled, () => {}), - ]).then(() => {}); + ]); void settled.catch(() => {}); return { address: bootstrap.address, ready: bootstrap.ready, close, settled }; } catch (error) { diff --git a/src/agent/hosted/executor-runtime-prepare-schema.ts b/src/agent/hosted/executor-runtime-prepare-schema.ts index c5695c9823..0ee4ceec92 100644 --- a/src/agent/hosted/executor-runtime-prepare-schema.ts +++ b/src/agent/hosted/executor-runtime-prepare-schema.ts @@ -2,6 +2,10 @@ import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts" import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; import { defineError, VeryfrontError } from "#veryfront/errors/types.ts"; import { getExecutorDiscoveryIdSchema } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { + getExecutorAgentFailureCodeSchema, + getExecutorPreparedRuntimeHandleSchema, +} from "#veryfront/agent/hosted/executor-agent-schema.ts"; const failureStatus = { EXECUTOR_RUNTIME_INVALID_INPUT: 400, @@ -14,6 +18,19 @@ const failureStatus = { EXECUTOR_RUNTIME_CLOSED: 410, ABORTED: 499, } as const; +type RuntimePreparationFailureCode = keyof typeof failureStatus; +const runtimePreparationFailureCodes = Object.keys(failureStatus) as [ + RuntimePreparationFailureCode, + ...RuntimePreparationFailureCode[], +]; +const getExecutorRuntimePreparationFailureCodeSchema = defineSchema((v) => + v.union([v.enum(runtimePreparationFailureCodes), getExecutorAgentFailureCodeSchema()]) +); +export function isExecutorRuntimePreparationFailureCode( + value: unknown, +): value is RuntimePreparationFailureCode { + return runtimePreparationFailureCodes.includes(value as RuntimePreparationFailureCode); +} export class ExecutorRuntimePreparationError extends VeryfrontError { constructor(readonly code: keyof typeof failureStatus) { super( @@ -58,6 +75,26 @@ export type ExecutorRuntimePrepareRequest = InferSchema< ReturnType >; +export const getExecutorRuntimePrepareResultSchema = defineSchema((v) => + v.discriminatedUnion("ok", [ + v.object({ + ok: v.literal(true), + value: v.object({ + preparedRuntimeHandle: getExecutorPreparedRuntimeHandleSchema(), + runtimeKind: v.literal("framework"), + modelId: getExecutorDiscoveryIdSchema(), + }).strict(), + }).strict(), + v.object({ + ok: v.literal(false), + code: getExecutorRuntimePreparationFailureCodeSchema(), + }).strict(), + ]) +); +export type ExecutorRuntimePrepareResult = InferSchema< + ReturnType +>; + export const getExecutorRuntimeGrantDataSchema = defineSchema((v) => { const context = { projectId: v.string().nullable(), diff --git a/src/agent/hosted/executor-runtime-settlement.test.ts b/src/agent/hosted/executor-runtime-settlement.test.ts new file mode 100644 index 0000000000..afd2b2dd42 --- /dev/null +++ b/src/agent/hosted/executor-runtime-settlement.test.ts @@ -0,0 +1,22 @@ +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import { awaitExecutorCleanup } from "./executor-runtime-settlement.ts"; + +it("retains channel retirement after runtime cleanup fails", async () => { + const channel = Promise.withResolvers(); + let settled = false; + const closing = awaitExecutorCleanup([ + Promise.reject(new Error("Synthetic cleanup failure")), + channel.promise, + ]); + void closing.then(() => { + settled = true; + }, () => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(settled, false); + channel.resolve(); + await assertRejects(() => closing); + assertEquals(settled, true); +}); diff --git a/src/agent/hosted/executor-runtime-settlement.ts b/src/agent/hosted/executor-runtime-settlement.ts new file mode 100644 index 0000000000..7ad3f9b7c2 --- /dev/null +++ b/src/agent/hosted/executor-runtime-settlement.ts @@ -0,0 +1,7 @@ +/** Join the executor runtime and channel retirement branches. */ +export async function awaitExecutorCleanup(tasks: readonly Promise[]): Promise { + const results = await Promise.allSettled(tasks); + if (results.some((result) => result.status === "rejected")) { + throw new Error("Executor cleanup failed"); + } +} diff --git a/src/agent/hosted/executor-session-pool.test.ts b/src/agent/hosted/executor-session-pool.test.ts index 0720dcbecb..d916cf1d14 100644 --- a/src/agent/hosted/executor-session-pool.test.ts +++ b/src/agent/hosted/executor-session-pool.test.ts @@ -27,6 +27,9 @@ function sessionDouble() { binding: undefined, accepted: false, accept() {}, + runOwned(operation) { + return operation(); + }, close() { closeCalls++; controller.abort(); diff --git a/src/agent/hosted/executor-session.test.ts b/src/agent/hosted/executor-session.test.ts index 9f91928c23..91b241dca6 100644 --- a/src/agent/hosted/executor-session.test.ts +++ b/src/agent/hosted/executor-session.test.ts @@ -1,5 +1,11 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { + assert, + assertEquals, + assertRejects, + assertStrictEquals, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createExecutorChannel, type ExecutorChannel } from "../executor/channel.ts"; import { ManualMonotonicClock } from "../streaming/lifecycle/testing.ts"; @@ -792,6 +798,55 @@ describe("hosted executor session", () => { } }); + it("retains broker-owned work through bounded close until the original promise settles", async () => { + const work = Promise.withResolvers(); + const f = fixture(); + const session = f.start(); + await session.ready; + const owned = session.runOwned(() => work.promise); + const closing = session.close(); + f.time.advanceBy(500); + assertEquals((await closing).release, "released"); + let settled = false; + void session.settled.then(() => settled = true); + await tick(); + assertEquals(settled, false); + work.resolve("persisted"); + assertEquals(await owned, "persisted"); + await session.settled; + assertEquals(settled, true); + await f.peerClosed(); + }); + + it("propagates owned-work rejection and retires it from settlement", async () => { + const f = fixture(); + const session = f.start(); + await session.ready; + const failure = new Error("synthetic owned failure"); + const rejected = await assertRejects(() => session.runOwned(() => Promise.reject(failure))); + assertStrictEquals(rejected, failure); + await session.close(); + await session.settled; + await f.peerClosed(); + }); + + it("rejects owned work after close without invoking the thunk", async () => { + const f = fixture(); + const session = f.start(); + await session.ready; + await session.close(); + let invoked = false; + await assertRejects(() => + session.runOwned(() => { + invoked = true; + return Promise.resolve(); + }) + ); + assertEquals(invoked, false); + await session.settled; + await f.peerClosed(); + }); + it("retains session admission through noncooperative incoming operation cleanup", async () => { const finish = Promise.withResolvers(); const started = Promise.withResolvers(); diff --git a/src/agent/hosted/executor-session.ts b/src/agent/hosted/executor-session.ts index 128d52403e..086b7b78dc 100644 --- a/src/agent/hosted/executor-session.ts +++ b/src/agent/hosted/executor-session.ts @@ -94,6 +94,11 @@ export interface HostedExecutorSession { readonly accepted: boolean; /** Call after remote preparation. Execution ownership detaches the preparation request before 202. */ accept(ownership: { kind: "request" } | { kind: "execution"; signal?: AbortSignal }): void; + /** + * Retain broker-local work outside channel handlers until its original promise settles. + * Owned work must not await this session's own close or settled promise. + */ + runOwned(operation: () => Promise): Promise; close(reason?: "completed" | "canceled"): Promise; } @@ -273,6 +278,14 @@ class Session implements HostedExecutorSession { this.#cancelTimer("preparation"); } + async runOwned(operation: () => Promise): Promise { + this.#assertActive(); + if (typeof operation !== "function") { + throw new TypeError("Executor owned work requires an operation"); + } + return await this.#track(operation); + } + close(reason: "completed" | "canceled" = "canceled"): Promise { this.#stop(reason); return this.closed; diff --git a/src/agent/hosted/managed-broker-persistence.test.ts b/src/agent/hosted/managed-broker-persistence.test.ts new file mode 100644 index 0000000000..0d7a2b6665 --- /dev/null +++ b/src/agent/hosted/managed-broker-persistence.test.ts @@ -0,0 +1,188 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { createManagedBrokerPersistence } from "./managed-broker-persistence.ts"; + +const conversationId = "00000000-0000-4000-8000-000000000001"; +const messageId = "00000000-0000-4000-8000-000000000002"; +const run = { + runId: "run-1", + conversationId, + messageId, + latestEventId: 0, + latestExternalEventSequence: 0, + waitingToolCallId: null, + waitingToolName: null, + status: "running" as const, + streamProtocolVersion: 2 as const, +}; + +function successfulFetch(calls: Record[]) { + let cursor = 0; + return async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + calls.push(body); + if (Array.isArray(body.events)) { + cursor += body.events.length; + return Response.json({ + latest_event_id: cursor, + latest_external_event_sequence: cursor, + appended_count: body.events.length, + run: { + run_id: run.runId, + conversation_id: conversationId, + latest_event_id: cursor, + latest_external_event_sequence: cursor, + }, + }); + } + return Response.json({ completed: true, run: { runId: run.runId, status: body.status } }); + }; +} + +describe("managed broker persistence", () => { + it("persists output, audit, parent events, checkpoints, and terminal completion", async () => { + const calls: Record[] = []; + const fetch = successfulFetch(calls); + await withMockFetch(fetch, async () => { + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "veryfront-cloud/openai/synthetic", + resolveProvider: () => "openai", + fetch, + }); + await persistence.output.write({ type: "text-delta", id: "message", delta: "hello" }); + await persistence.modelRunEventSink({ + type: "AGENT_RUN_MODEL_CALL_CONTEXT", + messages: [], + tools: [], + }); + await persistence.publishParentRunEvents([{ type: "STEP_STARTED" }]); + await persistence.persistToolExposureCheckpoint({ + version: 2, + loadedToolNames: ["search"], + }); + await persistence.persistProviderReplayCheckpoint({ + version: 1, + messageId, + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "synthetic" }, + }], + providerBlockPositions: [0], + providerMessageBlockCounts: [1], + totalPartCount: 1, + }); + await persistence.output.finish({ completed: true }); + await assertRejects(() => persistence.publishParentRunEvents([{ type: "STEP_FINISHED" }])); + await persistence.cleanup(); + }); + const events = calls.flatMap((call) => Array.isArray(call.events) ? call.events : []); + assertEquals(events.some((event) => event.type === "TEXT_MESSAGE_CONTENT"), true); + assertEquals(events.some((event) => event.type === "AGENT_RUN_MODEL_CALL_CONTEXT"), true); + assertEquals(events.some((event) => event.type === "STEP_STARTED"), true); + assertEquals(events.some((event) => event.type === "AGENT_RUN_TOOL_EXPOSURE_CHECKPOINT"), true); + assertEquals( + events.some((event) => event.type === "AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT"), + true, + ); + assertEquals(calls.at(-1)?.status, "completed"); + }); + + it("retains a queued cancellation finish until the original output write settles", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const calls: Record[] = []; + const fallback = successfulFetch(calls); + let first = true; + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + if (first) { + first = false; + entered.resolve(); + return await release.promise; + } + return await fallback(input, init); + }; + await withMockFetch(fetch, async () => { + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "model", + resolveProvider: () => "provider", + fetch, + }); + const write = persistence.output.write({ + type: "text-delta", + id: "message", + delta: "pending", + }); + await entered.promise; + let finished = false; + const finish = persistence.output.finish({ completed: false }).then(() => finished = true); + await Promise.resolve(); + assertEquals(finished, false); + release.resolve(Response.json({ + latest_event_id: 1, + latest_external_event_sequence: 1, + appended_count: 1, + run: { + run_id: run.runId, + conversation_id: conversationId, + latest_event_id: 1, + latest_external_event_sequence: 1, + }, + })); + await write; + await finish; + assertEquals(calls.at(-1)?.status, "cancelled"); + await persistence.cleanup(); + }); + }); + + it("propagates persistence failure through finish without reporting success", async () => { + const fetch = () => Promise.resolve(new Response("failed", { status: 500 })); + await withMockFetch(fetch, async () => { + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "model", + resolveProvider: () => "provider", + fetch, + }); + await assertRejects(() => + persistence.output.write({ type: "text-delta", id: "message", delta: "fail" }) + ); + await assertRejects(() => persistence.output.finish({ completed: false, error: "failed" })); + await persistence.cleanup(); + }); + }); + + it("persists a failed terminal outcome for executor output errors", async () => { + const calls: Record[] = []; + const fetch = successfulFetch(calls); + await withMockFetch(fetch, async () => { + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "model", + resolveProvider: () => "provider", + fetch, + }); + await persistence.output.finish({ + completed: false, + error: new Error("synthetic execution failure"), + }); + assertEquals(calls.at(-1)?.status, "failed"); + assertEquals(calls.at(-1)?.terminal_error_code, "STREAM_ERROR"); + await persistence.cleanup(); + }); + }); +}); diff --git a/src/agent/hosted/managed-broker-persistence.ts b/src/agent/hosted/managed-broker-persistence.ts new file mode 100644 index 0000000000..6590ef9610 --- /dev/null +++ b/src/agent/hosted/managed-broker-persistence.ts @@ -0,0 +1,150 @@ +import type { ChatMessageMetadata, ChatUiMessageChunk } from "#veryfront/chat/protocol.ts"; +import type { ConversationRunEvent } from "../conversation/run-events.ts"; +import { + type ConversationRunProjection, + getConversationRunProjectionSchema, +} from "../conversation/durable-contracts.ts"; +import { + createConversationHostedTerminalAdapter, + resolveConversationHostedStreamErrorState, +} from "../conversation/hosted-terminal.ts"; +import { createDurableRunEventSink } from "./durable-run-event-sink.ts"; +import { + createHostedConversationRunChunkMirrorFromCapability, + createHostedRunEventWriterCapability, +} from "./child-run-event-writer-token.ts"; +import { + createToolExposureCheckpointEvent, + type ToolExposureCheckpoint, +} from "../runtime/tool-exposure.ts"; +import { + createProviderReplayCheckpointEvent, + type ProviderReplayCheckpoint, +} from "../runtime/provider-replay.ts"; +import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; + +/** Acknowledging output writes and terminal finalization for a canonical run. */ +export interface ManagedBrokerOutput { + write(chunk: ChatUiMessageChunk): Promise; + finish(input: { completed: boolean; error?: unknown }): Promise; +} + +/** Create exact-run API persistence callbacks while retaining credentials in the broker. */ +export function createManagedBrokerPersistence(input: { + apiUrl: string; + runEventToken: string; + run: ConversationRunProjection; + modelId: string; + resolveProvider(modelId: string): string; + fetch?: typeof globalThis.fetch; +}) { + const run = getConversationRunProjectionSchema().parse(input.run); + if (run.status !== "pending" && run.status !== "running" && run.status !== "waiting_for_tool") { + throw new TypeError("Managed broker persistence requires an active run"); + } + const capability = createHostedRunEventWriterCapability({ + apiUrl: input.apiUrl, + runId: run.runId, + runEventAppendToken: input.runEventToken, + fetch: input.fetch, + }); + const mirror = createHostedConversationRunChunkMirrorFromCapability(capability, { + expectedRunId: run.runId, + conversationId: run.conversationId, + latestEventId: run.latestEventId, + latestExternalEventSequence: run.latestExternalEventSequence, + }); + if (!mirror) throw new TypeError("Managed broker run-event capability is not bound"); + const durableMirror = mirror; + const terminal = createConversationHostedTerminalAdapter({ + authToken: input.runEventToken, + apiUrl: input.apiUrl, + run, + fallbackModelId: input.modelId, + resolveProvider: input.resolveProvider, + }); + const durableSink = createDurableRunEventSink({ mirror: durableMirror }); + let tail = Promise.resolve(); + let failure: unknown; + let failed = false; + let finished = false; + let cleaned = false; + + const queue = (operation: () => Promise, terminal = false): Promise => { + if (cleaned) return Promise.reject(new TypeError("Managed broker persistence is closed")); + if (finished && !terminal) { + return Promise.reject(new TypeError("Managed broker persistence is finished")); + } + const current = tail.then(async () => { + if (failed) throw failure; + try { + return await operation(); + } catch (error) { + failure = error; + failed = true; + throw error; + } + }); + tail = current.then(() => undefined, () => undefined); + return current; + }; + const flush = async () => { + const snapshot = await durableMirror.flush({ throwOnTimeoutRetry: true }); + if (snapshot.disabled || snapshot.pendingEventCount > 0 || snapshot.inFlight) { + throw new TypeError("Managed broker output was not durably persisted"); + } + }; + const persistEvents = (events: ConversationRunEvent[]) => + queue(async () => { + await durableMirror.appendEvents(events); + await flush(); + }); + const modelRunEventSink: AgentRunEventSink = (event) => + queue(async () => await durableSink(event)); + const output: ManagedBrokerOutput = { + write(chunk) { + if (finished) return Promise.reject(new TypeError("Managed broker output is finished")); + return queue(async () => { + await durableMirror.handleChunk(chunk); + await flush(); + }); + }, + finish(result) { + if (finished) return Promise.reject(new TypeError("Managed broker output is finished")); + if (result.completed && result.error !== undefined) { + return Promise.reject(new TypeError("Completed managed output cannot carry an error")); + } + finished = true; + return queue(async () => { + await flush(); + if (result.completed) { + await terminal.dispatch({ status: "completed" }); + } else if (result.error !== undefined) { + await terminal.dispatch(resolveConversationHostedStreamErrorState(result.error)); + } else { + await terminal.dispatch({ + status: "cancelled", + terminalErrorCode: "ABORTED", + terminalErrorMessage: "Managed executor output was cancelled", + }); + } + }, true); + }, + }; + async function cleanup(): Promise { + if (cleaned) return; + cleaned = true; + await tail; + durableMirror.dispose(); + } + return { + modelRunEventSink, + publishParentRunEvents: persistEvents, + persistToolExposureCheckpoint: (checkpoint: ToolExposureCheckpoint) => + persistEvents([createToolExposureCheckpointEvent(checkpoint)]), + persistProviderReplayCheckpoint: (checkpoint: ProviderReplayCheckpoint) => + persistEvents([createProviderReplayCheckpointEvent(checkpoint)]), + output, + cleanup, + }; +} diff --git a/src/agent/hosted/managed-broker-project-state.test.ts b/src/agent/hosted/managed-broker-project-state.test.ts new file mode 100644 index 0000000000..af5d5f023d --- /dev/null +++ b/src/agent/hosted/managed-broker-project-state.test.ts @@ -0,0 +1,149 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createManagedBrokerProjectState } from "./managed-broker-project-state.ts"; + +const definition = { + id: "coder", + name: "Coder", + description: "Codes", + instructions: "Base instructions", + skills: false as const, +}; + +describe("managed broker project state", () => { + it("uses fixed project authorization and performs a complete refresh", async () => { + const calls: Array<{ url: URL; authorization: string | null }> = []; + let instructionRead = 0; + const fetch = (value: string, init: RequestInit) => { + const url = new URL(value); + calls.push({ url, authorization: new Headers(init.headers).get("authorization") }); + if (url.pathname.endsWith("/AGENTS.md")) { + instructionRead++; + return Promise.resolve(Response.json({ + path: "AGENTS.md", + content: `Project instructions ${instructionRead}`, + })); + } + return Promise.resolve(Response.json({ data: [], page_info: { next: null } })); + }; + const state = createManagedBrokerProjectState({ + apiUrl: "https://api.example.test", + authToken: "broker-token", + agentId: "coder", + projectId: "project-1", + branchId: "branch-1", + fetch, + }); + const prepared = await state.prepareProjectSteering({ + definition, + projectId: "project-1", + branchId: "branch-1", + signal: new AbortController().signal, + }); + assertEquals(prepared.initialProjectInstructions, "Project instructions 1"); + const refreshed = await state.refreshProjectSteering(new AbortController().signal); + assertEquals(JSON.stringify(refreshed).includes("Project instructions 2"), true); + assertEquals(calls.every((call) => call.authorization === "Bearer broker-token"), true); + assertEquals(calls.every((call) => call.url.origin === "https://api.example.test"), true); + await assertRejects(() => + state.prepareProjectSteering({ + definition, + projectId: "project-2", + branchId: "branch-1", + signal: new AbortController().signal, + }) + ); + }); + + it("uses no API lookup for a null project and forwards conversation cancellation", async () => { + let fetches = 0; + let conversationSignal: AbortSignal | undefined; + const state = createManagedBrokerProjectState({ + apiUrl: "https://api.example.test", + authToken: "broker-token", + agentId: "coder", + projectId: null, + fetch: () => { + fetches++; + return Promise.reject(new Error("must not fetch")); + }, + latestConversationUserText: (signal) => { + conversationSignal = signal; + return Promise.resolve(null); + }, + }); + const prepared = await state.prepareProjectSteering({ + definition, + projectId: null, + signal: new AbortController().signal, + }); + assertEquals(prepared.agent.id, "coder"); + await state.refreshProjectSteering(new AbortController().signal); + const controller = new AbortController(); + controller.abort(); + await assertRejects(() => state.latestConversationUserText!(controller.signal)); + assertEquals(conversationSignal, undefined); + assertEquals(fetches, 0); + }); + + it("cancels in-flight project helper reads", async () => { + const entered = Promise.withResolvers(); + const state = createManagedBrokerProjectState({ + apiUrl: "https://api.example.test", + authToken: "broker-token", + agentId: "coder", + projectId: "project-1", + fetch: (_url, init) => { + const signal = init.signal!; + entered.resolve(signal); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }, + }); + const controller = new AbortController(); + const pending = state.prepareProjectSteering({ + definition, + projectId: "project-1", + signal: controller.signal, + }); + const signal = await entered.promise; + controller.abort(); + await assertRejects(() => pending); + assertEquals(signal.aborted, true); + }); + + it("keeps local discovery and factories outside its dependency graph", async () => { + const output = await new Deno.Command(Deno.execPath(), { + cwd: new URL("../../../", import.meta.url), + args: ["info", "--frozen", "--json", "src/agent/hosted/managed-broker-project-state.ts"], + }).output(); + assertEquals(output.code, 0, new TextDecoder().decode(output.stderr)); + const graph = JSON.parse(new TextDecoder().decode(output.stdout)) as { + roots: string[]; + modules: Array<{ specifier: string; dependencies?: Array<{ code?: { specifier: string } }> }>; + }; + const modules = new Map(graph.modules.map((module) => [module.specifier, module])); + const visited = new Set(); + const pending = [...graph.roots]; + while (pending.length) { + const specifier = pending.shift()!; + if (visited.has(specifier)) continue; + visited.add(specifier); + for (const dependency of modules.get(specifier)?.dependencies ?? []) { + if (dependency.code) pending.push(dependency.code.specifier); + } + } + const forbidden = [ + "/src/config/loader.ts", + "/src/agent/factory.ts", + "/src/tool/factory.ts", + "/src/agent/hosted/executor-discovery-node.ts", + ]; + assertEquals( + [...visited].filter((specifier) => forbidden.some((path) => specifier.endsWith(path))), + [], + ); + }); +}); diff --git a/src/agent/hosted/managed-broker-project-state.ts b/src/agent/hosted/managed-broker-project-state.ts new file mode 100644 index 0000000000..157d056c61 --- /dev/null +++ b/src/agent/hosted/managed-broker-project-state.ts @@ -0,0 +1,140 @@ +import type { AgentSystem } from "../types.ts"; +import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +import { + createStrictRuntimeProjectFilesClient, + type RuntimeGetProjectFileOptions, + type RuntimeProjectFilesApiOptions, +} from "../runtime/project-files-client.ts"; +import { + getRuntimeProjectInstructions, + getRuntimeProjectSkillCatalog, +} from "../runtime/project-skill-catalog.ts"; +import { + resolveRuntimeSkillSelectorSnapshotForAgent, + type RuntimeSkillDefinition, +} from "../runtime/skill-metadata.ts"; +import { + assertResolvedSkillSelector, + createNoneSkillSelectorSnapshot, +} from "#veryfront/skill/selector.ts"; +import type { SkillDocumentParserProvider } from "#veryfront/extensions/parser/skill-document-parser.ts"; +import { buildInteractiveVeryfrontCloudRuntimeInstructions } from "./cloud-runtime-system-messages.ts"; +import type { HostedChatRuntimeProjectSteering } from "./chat-runtime-contract.ts"; + +type Scope = { projectId: string | null; branchId?: string | null }; + +export function createManagedBrokerProjectState( + options: Scope & { + apiUrl: string | URL; + authToken: string; + agentId: string; + builtinSkills?: readonly RuntimeSkillDefinition[]; + skillDocumentParserProvider?: SkillDocumentParserProvider; + environmentContext?: string; + fetch?: (url: string, init: RequestInit) => Promise; + latestConversationUserText?: (signal: AbortSignal) => Promise; + }, +) { + if (!options.agentId || !options.authToken) { + throw new TypeError("Managed broker project state requires fixed identity and authorization"); + } + const projectId = options.projectId; + const branchId = options.branchId; + const authToken = options.authToken; + const projectClient = createStrictRuntimeProjectFilesClient({ + apiUrl: new URL(options.apiUrl).toString(), + fetch: options.fetch, + }); + const builtinSkills = [...options.builtinSkills ?? []]; + let definition: RuntimeAgentMarkdownDefinition | undefined; + const assertScope = (input: Scope) => { + if (input.projectId !== projectId || input.branchId !== branchId) { + throw new TypeError("Managed broker project state scope cannot change"); + } + }; + const fileReader = (signal: AbortSignal) => async (input: RuntimeGetProjectFileOptions) => { + assertScope(input); + if (input.authToken !== authToken || projectId === null) { + throw new TypeError("Managed broker project authorization cannot change"); + } + return await projectClient.getProjectFile({ ...input, signal }); + }; + const fileLister = (signal: AbortSignal) => async (input: RuntimeProjectFilesApiOptions) => { + assertScope(input); + if (input.authToken !== authToken || projectId === null) { + throw new TypeError("Managed broker project authorization cannot change"); + } + return await projectClient.getProjectFiles({ ...input, signal }); + }; + const load = async (signal: AbortSignal) => { + signal.throwIfAborted(); + if (projectId === null) return { instructions: "", skills: [] as RuntimeSkillDefinition[] }; + const lookup = { projectId, branchId, authToken }; + const [instructions, skills] = await Promise.all([ + getRuntimeProjectInstructions({ ...lookup, getProjectFile: fileReader(signal) }), + getRuntimeProjectSkillCatalog({ + ...lookup, + getProjectFile: fileReader(signal), + getProjectFiles: fileLister(signal), + builtinSkills, + skillDocumentParserProvider: options.skillDocumentParserProvider, + }), + ]); + signal.throwIfAborted(); + return { instructions, skills }; + }; + const select = (agent: RuntimeAgentMarkdownDefinition, skills: RuntimeSkillDefinition[]) => { + if (agent.skills === false) return createNoneSkillSelectorSnapshot(); + const selected = resolveRuntimeSkillSelectorSnapshotForAgent({ + skills, + agentId: agent.id, + selector: agent.skills, + }); + assertResolvedSkillSelector(selected); + return selected; + }; + return { + async prepareProjectSteering( + input: Scope & { + definition: RuntimeAgentMarkdownDefinition; + signal: AbortSignal; + }, + ): Promise> { + assertScope(input); + if (input.definition.id !== options.agentId) { + throw new TypeError("Managed broker project agent cannot change"); + } + const loaded = await load(input.signal); + const selected = select(input.definition, loaded.skills); + definition = structuredClone(input.definition); + return { + agent: structuredClone(input.definition), + skillSelectorPolicy: selected.policy, + ...(options.environmentContext ? { environmentContext: options.environmentContext } : {}), + ...(loaded.instructions ? { initialProjectInstructions: loaded.instructions } : {}), + ...(selected.definitions.length ? { initialSkills: selected.definitions } : {}), + }; + }, + async refreshProjectSteering(signal: AbortSignal): Promise { + if (!definition) throw new TypeError("Managed broker project state is not prepared"); + const loaded = await load(signal); + const selected = select(definition, loaded.skills); + return buildInteractiveVeryfrontCloudRuntimeInstructions({ + agentConfig: definition, + projectId, + branchId, + instructions: loaded.instructions, + skills: selected.definitions, + environmentContext: options.environmentContext, + }); + }, + ...(options.latestConversationUserText + ? { + latestConversationUserText: async (signal: AbortSignal) => { + signal.throwIfAborted(); + return await options.latestConversationUserText!(signal); + }, + } + : {}), + }; +} diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts new file mode 100644 index 0000000000..397d9c0bb0 --- /dev/null +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -0,0 +1,489 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; +import type { ModelRuntime } from "#veryfront/provider/types.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { HostedExecutorSessionOptions } from "./executor-session.ts"; +import type { HostedExecutorAllocation } from "./executor-session-schema.ts"; +import type { ExecutorNodeTransport } from "./executor-node-transport.ts"; +import { + createManagedExecutorBroker, + type ManagedExecutorStartInput, +} from "./managed-executor-broker.ts"; +import { getExecutorRuntimePrepareResultSchema } from "./executor-runtime-prepare-schema.ts"; +import { readExecutorInitialCheckpoints } from "./executor-checkpoint-state.ts"; +import { executorStateOperations } from "./executor-state-schema.ts"; + +const modelId = "veryfront-cloud/openai/synthetic"; +const owner = { scopeKind: "project" as const, projectId: "project-test" }; +const source = { type: "release" as const, releaseId: "release-test" }; +const image = `registry.example.test/executor@sha256:${"a".repeat(64)}`; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function runtimeModel(): ModelRuntime { + return { + specificationVersion: "v2", + provider: "veryfront-cloud", + modelId: "synthetic", + doGenerate: () => Promise.resolve({ content: [], finishReason: "stop", usage: {} }), + doStream: () => + Promise.resolve({ + stream: new ReadableStream({ + start(c) { + c.close(); + }, + }), + }), + }; +} + +function fixture( + options: { + prepareFailure?: boolean; + prepareModelId?: string; + brokerReadWait?: boolean; + initialCheckpoint?: boolean; + } = {}, +) { + const now = Date.now(); + const request = { + allocationId: crypto.randomUUID(), + invocationId: crypto.randomUUID(), + owner, + source, + requestedAt: now, + prepareDeadlineAt: now + 10_000, + hardDeadlineAt: now + 60_000, + }; + const calls: string[] = []; + let peer: ReturnType | undefined; + let generation = 0; + let preparationDenied = false; + let executionAllowed = false; + let initialCheckpointRead = false; + const preparation = new AbortController(); + const prepareEntered = Promise.withResolvers(); + const prepareRelease = Promise.withResolvers(); + const allocator: HostedExecutorSessionOptions["allocator"] = { + allocate() { + calls.push("allocate"); + generation = 1; + return Promise.resolve(allocation("ready")); + }, + observe() { + calls.push("observe"); + return Promise.resolve(allocation("ready")); + }, + renew() { + calls.push("renew"); + return Promise.resolve(allocation("ready")); + }, + release(_binding, reason) { + calls.push(`release:${reason}`); + return Promise.resolve(allocation("released", reason)); + }, + }; + function allocation( + phase: "ready" | "released", + reason?: "completed" | "canceled", + ): HostedExecutorAllocation { + return { + binding: { + allocationId: request.allocationId, + invocationId: request.invocationId, + owner, + source, + generation, + brokerInstanceId: "broker-test", + }, + phase, + expiresAt: now + 30_000, + ...(reason ? { reason } : {}), + ...(phase === "ready" + ? { + endpoint: { + address: "192.0.2.10", + port: 8081, + podUid: "pod-test", + nodeName: "node-test", + image, + channelAuthenticated: false, + }, + } + : {}), + }; + } + const session: ManagedExecutorStartInput["session"] = { + request, + expectedBrokerInstanceId: "broker-test", + expectedImage: image, + allocator, + preparationSignal: preparation.signal, + pollIntervalMs: 1_000, + requestTimeoutMs: 1_000, + cleanupTimeoutMs: 50, + connectTransport(input): Promise { + calls.push("connect"); + const outbound = new TransformStream(); + const inbound = new TransformStream(); + const operations = new Map([ + ["runtime.install", { + mode: "unary", + async handle() { + calls.push("install"); + try { + await peer!.request("model.generate", { modelId, options: { prompt: [] } }); + } catch { + preparationDenied = true; + } + if (options.initialCheckpoint) { + const state = await readExecutorInitialCheckpoints({ + channel: peer!, + capabilityIds: { toolExposureCheckpoint: "tool-checkpoint" }, + }); + initialCheckpointRead = state.initialToolExposureCheckpoint?.loadedToolNames[0] === + "search"; + } + return { installed: true }; + }, + }], + ["agent.describe", { + mode: "unary", + handle() { + calls.push("describe"); + return { + ok: true, + value: { + source, + definition: { + id: "coder", + name: "Coder", + description: "Codes", + instructions: "Work", + }, + }, + }; + }, + }], + ["runtime.prepare", { + mode: "unary", + async handle(): Promise { + calls.push("prepare"); + if (options.brokerReadWait) { + await peer!.request(executorStateOperations.latestConversationUserText, { + capabilityId: "conversation-text", + }); + } + if (options.prepareFailure) { + return { ok: false, code: "EXECUTOR_RUNTIME_PREPARATION_FAILED" }; + } + return { + ok: true, + value: { + preparedRuntimeHandle: "prepared-1", + runtimeKind: "framework", + modelId: options.prepareModelId ?? modelId, + }, + }; + }, + }], + ["agent.stream", { + mode: "stream", + async *handle() { + calls.push("stream"); + await peer!.request("model.generate", { + modelId, + options: { prompt: [], maxOutputTokens: 100 }, + }); + executionAllowed = true; + yield { type: "ready" }; + await new Promise(() => {}); + }, + }], + ]); + peer = createExecutorChannel({ + binding: input.binding, + transport: { readable: outbound.readable, writable: inbound.writable }, + operations, + }); + return Promise.resolve({ + readable: inbound.readable, + writable: outbound.writable, + close() { + peer?.close(); + }, + }); + }, + }; + const installation: ManagedExecutorStartInput["installation"] = { + version: 1, + owner, + source, + root: "project", + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: [{ id: modelId, maxOutputTokens: 100, providerToolNames: [] }], + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, + }, + capabilities: { persistence: {} }, + }; + const input: ManagedExecutorStartInput = { + session, + installation, + prepare: { agentId: "coder" }, + model: { + resolver: (id) => id === modelId ? runtimeModel() : undefined, + grant: { + maxCalls: 4, + maxConcurrentCalls: 1, + models: new Map([[modelId, { maxOutputTokens: 100, providerTools: [] }]]), + }, + }, + tools: { sources: new Map(), maxCalls: 4, maxConcurrent: 1 }, + persistence: {}, + state: {}, + }; + if (options.initialCheckpoint) { + installation.capabilities.persistence.toolExposureCheckpoint = "tool-checkpoint"; + input.persistence = { + initialToolExposureCheckpoint: { version: 2, loadedToolNames: ["search"] }, + persistToolExposureCheckpoint: () => Promise.resolve(), + }; + } + if (options.brokerReadWait) { + installation.capabilities.conversationUserText = "conversation-text"; + input.state = { + latestConversationUserText: async () => { + prepareEntered.resolve(); + await prepareRelease.promise; + return "late text"; + }, + }; + } + return { + calls, + input, + preparation, + prepareEntered: prepareEntered.promise, + releasePrepare: prepareRelease.resolve, + get peer() { + return peer; + }, + get preparationDenied() { + return preparationDenied; + }, + get executionAllowed() { + return executionAllowed; + }, + get initialCheckpointRead() { + return initialCheckpointRead; + }, + }; +} + +describe("managed executor broker", () => { + it("installs, discovers, prepares, accepts, and begins execution in exact order", async () => { + const f = fixture({ initialCheckpoint: true }); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + let admitted = false; + const pending = broker.start(f.input, { + onAdmitted(settled) { + admitted = true; + assert(settled instanceof Promise); + }, + }); + assertEquals(admitted, true); + f.input.model.grant.models.get(modelId)!.maxOutputTokens = 1; + f.input.persistence.initialToolExposureCheckpoint!.loadedToolNames[0] = "changed"; + const runtime = await pending; + assertEquals(f.calls.slice(0, 5), ["allocate", "connect", "install", "describe", "prepare"]); + assertEquals(f.preparationDenied, true); + assertEquals(f.initialCheckpointRead, true); + assertEquals(runtime.definition.id, "coder"); + await assertRejects(() => + runtime.agent.stream({ messages: [], abortSignal: new AbortController().signal }) + ); + runtime.accept({ kind: "execution" }); + await runtime.agent.stream({ messages: [], abortSignal: new AbortController().signal }); + assertEquals(f.executionAllowed, true); + await runtime.close("completed"); + await runtime.settled; + await f.peer?.closed; + await broker.shutdown(); + await broker.settled; + assertEquals(broker.active, 0); + }); + + it("rejects canonical model dispatch without a real event sink before allocation", async () => { + const f = fixture(); + f.input.installation.grant.execution = { + kind: "canonical", + projectId: null, + conversationId: "conversation-1", + runId: "run-1", + messageId: "message-1", + providerReplay: "disabled", + }; + f.input.installation.capabilities.persistence = { + publishParentRunEvents: "parent-events", + toolExposureCheckpoint: "tool-checkpoint", + }; + f.input.persistence = { + publishParentRunEvents: () => Promise.resolve(), + persistToolExposureCheckpoint: () => Promise.resolve(), + }; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + await assertRejects(() => broker.start(f.input)); + assertEquals(f.calls, []); + assertEquals(broker.active, 0); + await broker.shutdown(); + + const ephemeral = fixture(); + ephemeral.input.model.runEventSink = () => Promise.resolve(); + const ephemeralBroker = createManagedExecutorBroker({ maxActive: 1 }); + await assertRejects(() => ephemeralBroker.start(ephemeral.input)); + assertEquals(ephemeral.calls, []); + await ephemeralBroker.shutdown(); + }); + + it("closes and releases a session when remote preparation fails", async () => { + const f = fixture({ prepareFailure: true }); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + await assertRejects(() => broker.start(f.input), Error, "EXECUTOR_RUNTIME_PREPARATION_FAILED"); + assert(f.calls.includes("release:canceled")); + assertEquals(broker.active, 0); + await f.peer?.closed; + await broker.shutdown(); + await broker.settled; + }); + + it("rejects a prepared model outside the broker selection", async () => { + const f = fixture({ prepareModelId: "veryfront-cloud/openai/other" }); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + await assertRejects(() => broker.start(f.input), Error, "EXECUTOR_RUNTIME_NOT_GRANTED"); + assert(f.calls.includes("release:canceled")); + await broker.shutdown(); + await broker.settled; + }); + + it("returns preparation failure after bounded close while retaining noncooperative work", async () => { + const f = fixture({ brokerReadWait: true }); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + const pending = broker.start(f.input); + await f.prepareEntered; + f.preparation.abort(); + await assertRejects(() => pending); + assertEquals(broker.active, 1); + const shutdown = broker.shutdown(); + f.releasePrepare(); + await shutdown; + await broker.settled; + assertEquals(broker.active, 0); + }); + + it("preserves request cancellation and transfers durable cancellation at acceptance", async () => { + const requestFixture = fixture(); + const requestBroker = createManagedExecutorBroker({ maxActive: 1 }); + const requestRuntime = await requestBroker.start(requestFixture.input); + requestRuntime.accept({ kind: "request" }); + requestFixture.preparation.abort(); + await requestRuntime.settled; + assert(requestFixture.calls.includes("release:canceled")); + await requestBroker.shutdown(); + + const durableFixture = fixture(); + const durableBroker = createManagedExecutorBroker({ maxActive: 1 }); + const durableRuntime = await durableBroker.start(durableFixture.input); + const execution = new AbortController(); + durableRuntime.accept({ kind: "execution", signal: execution.signal }); + durableFixture.preparation.abort(); + await tick(); + assertEquals(durableBroker.active, 1); + execution.abort(); + await durableRuntime.settled; + assert(durableFixture.calls.includes("release:canceled")); + await durableBroker.shutdown(); + }); + + it("retains pool admission until cancelled durable model persistence actually settles", async () => { + const f = fixture(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + f.input.installation.grant.execution = { + kind: "canonical", + projectId: null, + conversationId: "conversation-1", + runId: "run-1", + messageId: "message-1", + providerReplay: "disabled", + }; + f.input.installation.capabilities.persistence = { + publishParentRunEvents: "parent-events", + toolExposureCheckpoint: "tool-checkpoint", + }; + f.input.persistence = { + publishParentRunEvents: () => Promise.resolve(), + persistToolExposureCheckpoint: () => Promise.resolve(), + }; + f.input.model.runEventSink = async () => { + entered.resolve(); + await release.promise; + }; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + const runtime = await broker.start(f.input); + runtime.accept({ kind: "execution" }); + const opening = runtime.agent.stream({ + messages: [], + abortSignal: new AbortController().signal, + }); + void opening.catch(() => {}); + await entered.promise; + await runtime.close(); + let settled = false; + void runtime.settled.then(() => settled = true); + await tick(); + assertEquals(settled, false); + assertEquals(broker.active, 1); + release.resolve(); + await assertRejects(() => opening); + await runtime.settled; + assertEquals(broker.active, 0); + await f.peer?.closed; + await broker.shutdown(); + }); + + it("exports a strict preparation result schema", () => { + assertEquals( + getExecutorRuntimePrepareResultSchema().safeParse({ + ok: true, + value: { preparedRuntimeHandle: "handle", runtimeKind: "framework", modelId }, + }).success, + true, + ); + assertEquals( + getExecutorRuntimePrepareResultSchema().safeParse({ + ok: false, + code: "EXECUTOR_RUNTIME_PREPARATION_FAILED", + }).success, + true, + ); + assertEquals( + getExecutorRuntimePrepareResultSchema().safeParse({ + ok: true, + value: { + preparedRuntimeHandle: "handle", + runtimeKind: "framework", + modelId, + token: "secret", + }, + }).success, + false, + ); + }); +}); diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts new file mode 100644 index 0000000000..2a34c8945c --- /dev/null +++ b/src/agent/hosted/managed-executor-broker.ts @@ -0,0 +1,413 @@ +import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; +import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +import type { AgentModelRuntimeResolver } from "../runtime/model-transport.ts"; +import { + createExecutorOperationGate, + type ExecutorOperationGate, +} from "../executor/operation-gate.ts"; +import type { ExecutorBinding } from "../executor/protocol.ts"; +import type { HostedChatRuntimeAgent } from "./chat-runtime-contract.ts"; +import { + createHostedExecutorSessionPool, + type HostedExecutorSessionPoolOptions, +} from "./executor-session-pool.ts"; +import type { + HostedExecutorSessionCloseResult, + HostedExecutorSessionOptions, +} from "./executor-session.ts"; +import { sameHostedExecutorOwner } from "./executor-session-schema.ts"; +import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; +import { + type ExecutorRuntimeInstall, + getExecutorRuntimeInstallSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; +import { + ExecutorRuntimePreparationError, + type ExecutorRuntimePrepareRequest, + getExecutorRuntimePrepareRequestSchema, + getExecutorRuntimePrepareResultSchema, + isExecutorRuntimePreparationFailureCode, + parseRuntimePreparationData, +} from "./executor-runtime-prepare-schema.ts"; +import { + ExecutorDiscoveryError, + getExecutorAgentDescribeResultSchema, + parseDiscoveryData, +} from "./executor-discovery-schema.ts"; +import { ExecutorAgentError } from "./executor-agent-schema.ts"; +import { createExecutorHostedChatRuntimeAgent } from "./executor-agent-bridge.ts"; +import { + createEphemeralHostedExecutorModelBroker, + createHostedExecutorModelBroker, +} from "./executor-model-dispatch.ts"; +import type { ExecutorModelGrant } from "./executor-model-grant.ts"; +import { createExecutorToolBroker, type ExecutorToolCapability } from "./executor-tool-bridge.ts"; +import { createExecutorPersistenceBroker } from "./executor-persistence-bridge.ts"; +import { executorInitialCheckpointsOperation } from "./executor-checkpoint-state.ts"; +import { createExecutorStateBroker } from "./executor-state-bridge.ts"; +import { executorStateOperations } from "./executor-state-schema.ts"; +import type { ExecutorOperation } from "../executor/channel.ts"; + +type SessionInput = Omit; +type InstallInput = Omit; +type PersistenceInput = Omit< + Parameters[0], + "expectedBinding" | "capabilityIds" +>; +type StateInput = Omit< + Parameters[0], + "expectedBinding" | "capabilityIds" | "agentId" | "projectId" | "branchId" +>; + +/** Prepared executor handle with broker-owned execution and retirement. */ +export interface ManagedExecutorRuntime { + readonly definition: RuntimeAgentMarkdownDefinition; + readonly modelId: string; + readonly runtimeKind: "framework"; + readonly agent: HostedChatRuntimeAgent; + readonly settled: Promise; + readonly accepted: boolean; + /** Broker-only work; must not await this runtime's close or settled promise. */ + runOwned(operation: () => Promise): Promise; + accept(ownership: { kind: "request" } | { kind: "execution"; signal?: AbortSignal }): void; + close(reason?: "completed" | "canceled"): Promise; +} + +/** Trusted per-invocation source, model, tool, persistence, and state authority. */ +export interface ManagedExecutorStartInput { + session: SessionInput; + installation: InstallInput; + prepare: ExecutorRuntimePrepareRequest; + model: { + resolver: AgentModelRuntimeResolver; + grant: ExecutorModelGrant; + runEventSink?: AgentRunEventSink; + }; + tools: { + sources: ReadonlyMap; + maxCalls: number; + maxConcurrent: number; + limits?: Parameters[0]["limits"]; + }; + persistence: PersistenceInput; + state: StateInput; +} +type ManagedExecutorOperationInput = Pick< + ManagedExecutorStartInput, + "model" | "tools" | "persistence" | "state" +>; + +/** Process admission and shutdown limits for a managed broker. */ +export type ManagedExecutorBrokerOptions = Omit< + HostedExecutorSessionPoolOptions, + "createSession" +>; + +/** Compose an executor pool with authenticated installation and operation gates. */ +export function createManagedExecutorBroker(options: ManagedExecutorBrokerOptions) { + const pool = createHostedExecutorSessionPool(options); + + async function start( + input: ManagedExecutorStartInput, + lifecycle: { onAdmitted?(settled: Promise): void } = {}, + ): Promise { + // Generation one is used only to validate local operation descriptors. + // Actual authority is rebuilt against the allocator's authenticated binding. + const validationBinding: ExecutorBinding = { + allocationId: input.session.request.allocationId, + generation: 1, + invocationId: input.session.request.invocationId, + }; + const installation = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), { + ...input.installation, + binding: validationBinding, + }); + const prepare = parseRuntimePreparationData( + getExecutorRuntimePrepareRequestSchema(), + input.prepare, + ); + if ( + !sameHostedExecutorOwner(installation.owner, input.session.request.owner) || + verifyHostedRuntimeSourceBinding(input.session.request.source, installation.source) !== + undefined || + prepare.agentId !== installation.grant.agentId + ) throw new TypeError("Managed executor installation does not match its session"); + const allowedModelIds = new Set(installation.grant.models.map((model) => model.id)); + const operationInput = snapshotOperationInput(input); + if ( + installation.grant.execution.kind === "ephemeral" && + operationInput.model.runEventSink !== undefined + ) { + throw new TypeError("Ephemeral executor model dispatch cannot receive a run event sink"); + } + // Validate every trusted capability before reserving pool admission. + buildBrokerOperations( + validationBinding, + new AbortController().signal, + operationInput, + installation, + allowedModelIds, + ); + + let gate: ExecutorOperationGate | undefined; + const session = pool.start({ + ...input.session, + createOperations(binding, signal) { + const channelBinding = toChannelBinding(binding); + const operations = buildBrokerOperations( + channelBinding, + signal, + operationInput, + installation, + allowedModelIds, + ); + gate = createExecutorOperationGate({ + binding: channelBinding, + signal, + operations, + preparationOperations: new Set([ + ...Object.values(executorStateOperations), + executorInitialCheckpointsOperation, + ].filter((name) => operations.has(name))), + }); + return { operations: gate.operations, revoke: gate.revoke }; + }, + }); + try { + lifecycle.onAdmitted?.(session.settled); + const channel = await session.ready; + const binding = session.binding; + if (!binding || !gate) throw new Error("Managed executor session is not bound"); + const channelBinding = toChannelBinding(binding); + const installRequest = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), { + ...installation, + binding: channelBinding, + }); + const installed = await channel.request("runtime.install", installRequest); + if ( + !installed || typeof installed !== "object" || Array.isArray(installed) || + Object.keys(installed).length !== 1 || installed.installed !== true + ) throw new Error("Managed executor installation acknowledgement is invalid"); + const description = parseDiscoveryData( + getExecutorAgentDescribeResultSchema(), + await channel.request("agent.describe", { agentId: prepare.agentId }), + true, + ); + if (!description.ok) throw new ExecutorDiscoveryError(description.code); + if ( + description.value.definition.id !== prepare.agentId || + verifyHostedRuntimeSourceBinding(installation.source, description.value.source) !== + undefined + ) throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_OUTPUT"); + const prepared = parseRuntimePreparationData( + getExecutorRuntimePrepareResultSchema(), + await channel.request("runtime.prepare", prepare), + ); + if (!prepared.ok) { + if (isExecutorRuntimePreparationFailureCode(prepared.code)) { + throw new ExecutorRuntimePreparationError(prepared.code); + } + throw new ExecutorAgentError(prepared.code); + } + const selectedModelId = prepare.modelId ?? installation.grant.defaultModelId; + if ( + !allowedModelIds.has(prepared.value.modelId) || prepared.value.modelId !== selectedModelId + ) { + throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_NOT_GRANTED"); + } + gate.markPrepared(); + const remoteAgent = createExecutorHostedChatRuntimeAgent({ + channel, + preparedRuntimeHandle: prepared.value.preparedRuntimeHandle, + }); + const agent: HostedChatRuntimeAgent = { + async stream(streamInput) { + if (!session.accepted || gate!.state !== "prepared") { + throw new Error("Managed executor runtime is not accepted"); + } + gate!.beginExecution(); + return await remoteAgent.stream(streamInput); + }, + }; + const settled = Promise.all([session.settled, gate.settled]).then(() => undefined); + return { + definition: description.value.definition, + modelId: prepared.value.modelId, + runtimeKind: prepared.value.runtimeKind, + agent, + settled, + runOwned: session.runOwned.bind(session), + get accepted() { + return session.accepted; + }, + accept(ownership) { + session.accept(ownership); + }, + close(reason = "canceled") { + gate!.revoke(); + return session.close(reason); + }, + } as ManagedExecutorRuntime; + } catch (error) { + gate?.revoke(); + await session.close("canceled").catch(() => {}); + // The pool retains admission until raw session/gate work settles. Startup + // failure returns after bounded close notification, even for noncooperative work. + void Promise.allSettled([session.settled, gate?.settled]); + throw error; + } + } + + return { + get active() { + return pool.active; + }, + signal: pool.signal, + closed: pool.closed, + settled: pool.settled, + start, + shutdown: pool.shutdown.bind(pool), + }; +} + +function buildBrokerOperations( + binding: ExecutorBinding, + signal: AbortSignal, + input: ManagedExecutorOperationInput, + installation: ExecutorRuntimeInstall, + allowedModelIds: ReadonlySet, +): ReadonlyMap { + const scope = { binding, signal, assertActive: () => signal.throwIfAborted() }; + const model = installation.grant.execution.kind === "canonical" + ? createHostedExecutorModelBroker({ + resolveModelRuntime: input.model.resolver, + allowedModelIds, + scope, + grant: input.model.grant, + runEventSink: input.model.runEventSink, + }) + : createEphemeralHostedExecutorModelBroker({ + resolveModelRuntime: input.model.resolver, + allowedModelIds, + scope, + grant: input.model.grant, + prepared: { conversationId: null, canonicalRootRun: null }, + }); + const tools = createExecutorToolBroker({ scope, ...input.tools }); + const persistence = createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: installation.capabilities.persistence, + ...input.persistence, + }); + const execution = installation.grant.execution; + const state = createExecutorStateBroker({ + expectedBinding: binding, + capabilityIds: { + projectSteering: installation.capabilities.projectSteering, + conversationUserText: installation.capabilities.conversationUserText, + }, + agentId: installation.grant.agentId, + projectId: execution.projectId, + branchId: execution.branchId, + ...input.state, + }); + const combined = new Map(); + for (const operations of [model, tools, persistence, state]) { + for (const [name, operation] of operations) { + if (combined.has(name)) throw new TypeError("Managed executor operation collision"); + combined.set(name, operation); + } + } + return combined; +} + +function snapshotOperationInput(input: ManagedExecutorStartInput): ManagedExecutorOperationInput { + const sources = new Map(); + for (const [id, capability] of input.tools.sources) { + const source = capability.source; + const context = capability.context; + sources.set(id, { + source: Object.freeze({ + id: source.id, + listTools: source.listTools.bind(source), + executeTool: source.executeTool.bind(source), + }), + allowedToolNames: new Set(capability.allowedToolNames), + context: Object.freeze({ + ...context, + ...(context.publishDataEvent + ? { publishDataEvent: context.publishDataEvent.bind(context) } + : {}), + }), + }); + } + return { + model: { + resolver: input.model.resolver, + grant: { + maxCalls: input.model.grant.maxCalls, + maxConcurrentCalls: input.model.grant.maxConcurrentCalls, + models: new Map([...input.model.grant.models].map(([id, policy]) => [id, { + maxOutputTokens: policy.maxOutputTokens, + providerTools: structuredClone(policy.providerTools), + }])), + }, + ...(input.model.runEventSink ? { runEventSink: input.model.runEventSink } : {}), + }, + tools: { + sources, + maxCalls: input.tools.maxCalls, + maxConcurrent: input.tools.maxConcurrent, + ...(input.tools.limits ? { limits: { ...input.tools.limits } } : {}), + }, + persistence: { + ...(input.persistence.initialToolExposureCheckpoint + ? { + initialToolExposureCheckpoint: structuredClone( + input.persistence.initialToolExposureCheckpoint, + ), + } + : {}), + ...(input.persistence.initialProviderReplayCheckpoints + ? { + initialProviderReplayCheckpoints: structuredClone( + input.persistence.initialProviderReplayCheckpoints, + ), + } + : {}), + ...(input.persistence.publishParentRunEvents + ? { publishParentRunEvents: input.persistence.publishParentRunEvents } + : {}), + ...(input.persistence.persistToolExposureCheckpoint + ? { persistToolExposureCheckpoint: input.persistence.persistToolExposureCheckpoint } + : {}), + ...(input.persistence.persistProviderReplayCheckpoint + ? { persistProviderReplayCheckpoint: input.persistence.persistProviderReplayCheckpoint } + : {}), + }, + state: { + ...(input.state.prepareProjectSteering + ? { prepareProjectSteering: input.state.prepareProjectSteering } + : {}), + ...(input.state.refreshProjectSteering + ? { refreshProjectSteering: input.state.refreshProjectSteering } + : {}), + ...(input.state.latestConversationUserText + ? { latestConversationUserText: input.state.latestConversationUserText } + : {}), + }, + }; +} + +function toChannelBinding(binding: { + allocationId: string; + generation: number; + invocationId: string; +}): ExecutorBinding { + return { + allocationId: binding.allocationId, + generation: binding.generation, + invocationId: binding.invocationId, + }; +} diff --git a/src/agent/service/broker-ingress.test.ts b/src/agent/service/broker-ingress.test.ts new file mode 100644 index 0000000000..5e8bd61e23 --- /dev/null +++ b/src/agent/service/broker-ingress.test.ts @@ -0,0 +1,293 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createControlPlaneSignature } from "#veryfront/server/handlers/request/internal-agent-run.test-helpers.ts"; +import { BrokerIngressError, parseBrokerRuntimeAgentIngress } from "./broker-ingress.ts"; + +const projectId = "00000000-0000-4000-8000-000000000005"; +const userId = "00000000-0000-4000-8000-000000000006"; +const conversationId = "00000000-0000-4000-8000-000000000001"; +const messageId = "00000000-0000-4000-8000-000000000002"; +const inputAnchorMessageId = "00000000-0000-4000-8000-000000000003"; +const path = "/api/control-plane/runs/run-1/stream"; + +function invocation(overrides: Record = {}) { + return { + run: { + agentServiceId: "service-1", + agentId: "builder", + conversationId, + runId: "run-1", + messageId, + inputAnchorMessageId, + requestedByUserId: userId, + project: { projectId, projectSlug: "demo-project", runtimeTargetKind: "main_branch" }, + }, + messages: [], + tools: [], + context: [], + agentSource: { type: "release", releaseId: "release-1" }, + credentials: { authToken: "api-auth-token", inferenceAuthToken: "inference-token" }, + ...overrides, + }; +} + +async function signedRequest( + bodyValue = invocation(), + overrides: Parameters[1] = {}, +) { + const rawBody = JSON.stringify(bodyValue); + const signature = await createControlPlaneSignature(rawBody, { + audience: "demo-project", + projectId, + requestId: "run-1", + requestPath: path, + ...overrides, + }); + return { + publicKeyPem: signature.publicKeyPem, + request: new Request(`https://broker.test${path}`, { + method: "POST", + headers: { + authorization: "Bearer broker-token", + "content-type": "application/json", + "x-veryfront-control-plane-jws": signature.jws, + "x-veryfront-run-event-token": "run-event-token", + }, + body: rawBody, + }), + }; +} + +function options(publicKeyPem: string) { + return { + publicKeyPem, + audience: "demo-project", + projectId, + expectedRunId: "run-1", + expectedSurface: "studio" as const, + boundSource: { type: "release" as const, releaseId: "release-1" }, + expectedOwner: { scopeKind: "project" as const, projectId }, + authorizeScope: (input: { + authorization: string; + apiAuthToken: string; + runEventToken: string; + }) => { + assertEquals(input.authorization, "Bearer broker-token"); + assertEquals(input.apiAuthToken, "api-auth-token"); + assertEquals(input.runEventToken, "run-event-token"); + return Promise.resolve({ principal: { userId }, runEventWriter: { id: "writer-1" } }); + }, + }; +} + +describe("managed broker ingress", () => { + it("verifies the exact body and produces disjoint private authority and executor data", async () => { + const signed = await signedRequest(); + const result = await parseBrokerRuntimeAgentIngress( + signed.request, + options(signed.publicKeyPem), + ); + assertEquals(result.privateAuthority.inferenceAuthToken, "inference-token"); + assertEquals(result.privateAuthority.apiAuthToken, "api-auth-token"); + assertEquals(result.privateAuthority.runEventToken, "run-event-token"); + assertEquals(result.privateAuthority.inboundAuthorization, "Bearer broker-token"); + assertEquals(result.executor.run.runId, "run-1"); + assertEquals(result.executor.input.runId, "run-1"); + const visible = JSON.stringify(result.executor); + for ( + const secret of ["broker-token", "api-auth-token", "run-event-token", "inference-token"] + ) { + assertEquals(visible.includes(secret), false); + } + assertEquals(signed.request.bodyUsed, true); + }); + + it("rejects invalid signatures and signed method, path, or run mismatches", async () => { + const signed = await signedRequest(); + const invalid = new Request(signed.request.url, { + method: "POST", + headers: signed.request.headers, + body: `${JSON.stringify(invocation())} `, + }); + await assertIngressError( + () => parseBrokerRuntimeAgentIngress(invalid, options(signed.publicKeyPem)), + 401, + "BROKER_INGRESS_AUTH_INVALID", + ); + + const wrongRun = await signedRequest( + invocation({ run: { ...invocation().run, runId: "run-2" } }), + ); + await assertIngressError( + () => parseBrokerRuntimeAgentIngress(wrongRun.request, options(wrongRun.publicKeyPem)), + 400, + "CONTROL_PLANE_RUN_ID_MISMATCH", + ); + }); + + it("rejects invalid, oversized, and aborted bodies before authorization", async () => { + let authorizations = 0; + const signed = await signedRequest(); + const invalidBody = "{"; + const invalidSignature = await createControlPlaneSignature(invalidBody, { + audience: "demo-project", + projectId, + requestId: "run-1", + requestPath: path, + }); + const invalid = new Request(`https://broker.test${path}`, { + method: "POST", + headers: { + authorization: "Bearer broker-token", + "x-veryfront-control-plane-jws": invalidSignature.jws, + "x-veryfront-run-event-token": "run-event-token", + }, + body: invalidBody, + }); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(invalid, { + ...options(invalidSignature.publicKeyPem), + authorizeScope: () => { + authorizations++; + return Promise.resolve({}); + }, + }), + 400, + "BROKER_INGRESS_INVALID_BODY", + ); + + const oversized = new Request(`https://broker.test${path}`, { + method: "POST", + headers: signed.request.headers, + body: "x".repeat(1024 * 1024 + 1), + }); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(oversized, { + ...options(signed.publicKeyPem), + authorizeScope: () => { + authorizations++; + return Promise.resolve({}); + }, + }), + 413, + "BROKER_INGRESS_BODY_TOO_LARGE", + ); + + const controller = new AbortController(); + const aborted = new Request( + `https://broker.test${path}`, + { + method: "POST", + headers: signed.request.headers, + body: new ReadableStream(), + signal: controller.signal, + duplex: "half", + } as RequestInit, + ); + const pending = parseBrokerRuntimeAgentIngress(aborted, { + ...options(signed.publicKeyPem), + authorizeScope: () => { + authorizations++; + return Promise.resolve({}); + }, + readTimeoutMs: 1_000, + }); + controller.abort(); + await assertIngressError(() => pending, 499, "BROKER_INGRESS_ABORTED"); + + const timedOut = new Request( + `https://broker.test${path}`, + { + method: "POST", + headers: signed.request.headers, + body: new ReadableStream(), + duplex: "half", + } as RequestInit, + ); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(timedOut, { + ...options(signed.publicKeyPem), + authorizeScope: () => { + authorizations++; + return Promise.resolve({}); + }, + readTimeoutMs: 1, + }), + 408, + "BROKER_INGRESS_TIMEOUT", + ); + assertEquals(authorizations, 0); + }); + + it("fails closed on source, project, owner, and required credential scope", async () => { + const signed = await signedRequest(); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(signed.request.clone(), { + ...options(signed.publicKeyPem), + boundSource: { type: "release", releaseId: "release-2" }, + }), + 409, + "CONTROL_PLANE_AGENT_SOURCE_MISMATCH", + ); + + const wrongProject = await signedRequest( + invocation({ + run: { + ...invocation().run, + project: { + projectId: "00000000-0000-4000-8000-000000000099", + projectSlug: "demo-project", + runtimeTargetKind: "main_branch", + }, + }, + }), + ); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(wrongProject.request, options(wrongProject.publicKeyPem)), + 403, + "BROKER_INGRESS_SCOPE_DENIED", + ); + + const noEventToken = await signedRequest(); + noEventToken.request.headers.delete("x-veryfront-run-event-token"); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(noEventToken.request, options(noEventToken.publicKeyPem)), + 401, + "BROKER_INGRESS_AUTH_REQUIRED", + ); + + const noApiToken = await signedRequest(invocation({ credentials: undefined })); + await assertIngressError( + () => parseBrokerRuntimeAgentIngress(noApiToken.request, options(noApiToken.publicKeyPem)), + 403, + "BROKER_INGRESS_SCOPE_DENIED", + ); + + const denied = await signedRequest(); + await assertIngressError( + () => + parseBrokerRuntimeAgentIngress(denied.request, { + ...options(denied.publicKeyPem), + authorizeScope: () => Promise.resolve(undefined), + }), + 403, + "BROKER_INGRESS_SCOPE_DENIED", + ); + }); +}); + +async function assertIngressError( + operation: () => Promise, + status: number, + errorCode: string, +) { + const error = await assertRejects(operation, BrokerIngressError) as BrokerIngressError; + assertEquals({ status: error.status, errorCode: error.errorCode }, { status, errorCode }); +} diff --git a/src/agent/service/broker-ingress.ts b/src/agent/service/broker-ingress.ts new file mode 100644 index 0000000000..d320c40173 --- /dev/null +++ b/src/agent/service/broker-ingress.ts @@ -0,0 +1,306 @@ +import type { ControlPlaneClaims, ControlPlaneSurface } from "#veryfront/channels/control-plane.ts"; +import { + CONTROL_PLANE_JWS_HEADER, + verifyControlPlaneJws, +} from "#veryfront/channels/control-plane.ts"; +import { + buildRuntimeAgentControlPlaneStreamRequestFromInvocation, + getRuntimeAgentRunIdSchema, + type RuntimeAgentRunContext, + type RuntimeAgentRunInvocation, + safeParseRuntimeAgentRunInvocationValue, +} from "#veryfront/agent/runtime/agent-invocation-contract.ts"; +import { + getInternalAgentControlPlaneStreamRequestSchema, + type RuntimeRunAgentInput, + toRuntimeRunAgentInput, +} from "#veryfront/internal-agents/schema.ts"; +import { + isRequestBodyTooLargeError, + readBodyBytesWithLimit, +} from "#veryfront/security/input-validation/limits.ts"; +import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import { + getHostedExecutorOwnerSchema, + type HostedExecutorOwner, +} from "#veryfront/agent/hosted/executor-session-schema.ts"; +import { + type HostedRuntimeSourceIdentity, + verifyHostedRuntimeSourceBinding, +} from "#veryfront/agent/hosted/runtime-source-binding.ts"; + +const BROKER_INGRESS_MAX_BODY_BYTES = DEFAULT_MAX_BODY_SIZE_BYTES; +const RUN_EVENT_APPEND_TOKEN_HEADER = "x-veryfront-run-event-token"; +const DEFAULT_BODY_READ_TIMEOUT_MS = 30_000; +const MAX_BODY_READ_TIMEOUT_MS = 60_000; +const fatalDecoder = new TextDecoder("utf-8", { fatal: true }); +const forbiddenForwardedAuthorityFields = new Set([ + "authorization", + "authtoken", + "inferenceauthtoken", + "credential", + "credentials", + "runeventtoken", +]); + +/** Fixed, credential-free ingress failure identifiers. */ +export type BrokerIngressErrorCode = + | "BROKER_INGRESS_INVALID_BODY" + | "BROKER_INGRESS_BODY_TOO_LARGE" + | "BROKER_INGRESS_ABORTED" + | "BROKER_INGRESS_TIMEOUT" + | "BROKER_INGRESS_AUTH_REQUIRED" + | "BROKER_INGRESS_AUTH_INVALID" + | "BROKER_INGRESS_SCOPE_DENIED" + | "BROKER_INGRESS_SCOPE_FAILED" + | "BROKER_INGRESS_TARGET_MISMATCH" + | "CONTROL_PLANE_RUN_ID_MISMATCH" + | "CONTROL_PLANE_AGENT_SOURCE_UNBOUND" + | "CONTROL_PLANE_AGENT_SOURCE_UNSUPPORTED" + | "CONTROL_PLANE_AGENT_SOURCE_MISMATCH"; + +/** Fixed ingress failures contain no body, signature, credential, or verifier diagnostics. */ +export class BrokerIngressError extends Error { + constructor(readonly status: number, readonly errorCode: BrokerIngressErrorCode) { + super(errorCode); + this.name = "BrokerIngressError"; + } +} + +/** Validated application data that can cross the executor channel. */ +export interface BrokerRuntimeAgentExecutorInput { + readonly owner: HostedExecutorOwner; + readonly run: Omit & { + validatedClaims?: RuntimeAgentRunContext["validatedClaims"]; + }; + readonly taskId?: string; + readonly agentSource: RuntimeAgentRunInvocation["agentSource"]; + readonly agentConfig?: RuntimeAgentRunInvocation["agentConfig"]; + readonly input: RuntimeRunAgentInput; +} + +/** HTTP credentials and verified authority retained exclusively in the broker. */ +export interface BrokerRuntimeAgentPrivateAuthority { + readonly owner: HostedExecutorOwner; + readonly claims: Readonly; + readonly inboundAuthorization: string; + readonly apiAuthToken: string; + readonly runEventToken: string; + readonly inferenceAuthToken?: string; + readonly authorization: TAuthorization; + readonly rawBody: string; +} + +/** Signed identity and credentials supplied to the trusted scope verifier. */ +export interface BrokerIngressScopeInput { + readonly owner: HostedExecutorOwner; + readonly claims: Readonly; + readonly run: RuntimeAgentRunContext; + readonly authorization: string; + readonly apiAuthToken: string; + readonly runEventToken: string; +} + +/** Broker-owned verification policy for one expected run and source. */ +export interface BrokerRuntimeAgentIngressOptions { + publicKeyPem: string; + audience: string; + projectId: string; + expectedRunId: string; + expectedSurface: ControlPlaneSurface; + boundSource: HostedRuntimeSourceIdentity | undefined; + expectedOwner: HostedExecutorOwner; + authorizeScope( + input: BrokerIngressScopeInput, + ): TAuthorization | undefined | Promise; + signal?: AbortSignal; + readTimeoutMs?: number; +} + +/** Separate private authority and executor-safe invocation data. */ +export interface BrokerRuntimeAgentIngress { + privateAuthority: BrokerRuntimeAgentPrivateAuthority; + executor: BrokerRuntimeAgentExecutorInput; +} + +/** Read and verify a signed invocation once before constructing executor-safe data. */ +export async function parseBrokerRuntimeAgentIngress( + request: Request, + options: BrokerRuntimeAgentIngressOptions, +): Promise> { + const expectedRunId = getRuntimeAgentRunIdSchema().parse(options.expectedRunId); + const expectedPath = `/api/control-plane/runs/${expectedRunId}/stream`; + const actualPath = new URL(request.url).pathname; + if (request.method !== "POST" || actualPath !== expectedPath) { + throw new BrokerIngressError(400, "BROKER_INGRESS_TARGET_MISMATCH"); + } + const inboundAuthorization = request.headers.get("authorization"); + const signature = request.headers.get(CONTROL_PLANE_JWS_HEADER); + const runEventToken = request.headers.get(RUN_EVENT_APPEND_TOKEN_HEADER); + if ( + !inboundAuthorization || inboundAuthorization.length > 16 * 1024 || !signature || + !runEventToken || runEventToken.length > 16 * 1024 + ) { + throw new BrokerIngressError(401, "BROKER_INGRESS_AUTH_REQUIRED"); + } + const ownerResult = getHostedExecutorOwnerSchema().safeParse(options.expectedOwner); + if (!ownerResult.success) throw new TypeError("Invalid broker ingress owner"); + const owner = Object.freeze(ownerResult.data); + if (owner.scopeKind === "project" && owner.projectId !== options.projectId) { + throw new TypeError("Broker ingress owner does not match its project scope"); + } + + const timeoutMs = options.readTimeoutMs ?? DEFAULT_BODY_READ_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_BODY_READ_TIMEOUT_MS) { + throw new TypeError("Invalid broker ingress body timeout"); + } + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signals = [request.signal, timeoutSignal, ...(options.signal ? [options.signal] : [])]; + const readSignal = AbortSignal.any(signals); + let rawBody: string; + try { + const bytes = await readBodyBytesWithLimit(request, BROKER_INGRESS_MAX_BODY_BYTES, { + signal: readSignal, + }); + rawBody = fatalDecoder.decode(bytes); + } catch (error) { + if (isRequestBodyTooLargeError(error)) { + throw new BrokerIngressError(413, "BROKER_INGRESS_BODY_TOO_LARGE"); + } + if (readSignal.aborted) { + throw new BrokerIngressError( + timeoutSignal.aborted && !request.signal.aborted && !options.signal?.aborted ? 408 : 499, + timeoutSignal.aborted && !request.signal.aborted && !options.signal?.aborted + ? "BROKER_INGRESS_TIMEOUT" + : "BROKER_INGRESS_ABORTED", + ); + } + throw new BrokerIngressError(400, "BROKER_INGRESS_INVALID_BODY"); + } + + let claims: ControlPlaneClaims; + try { + claims = await verifyControlPlaneJws(signature, rawBody, { + audience: options.audience, + expectedProjectId: options.projectId, + expectedSubject: expectedRunId, + expectedSurface: options.expectedSurface, + maxAgeSeconds: 60, + publicKeyPem: options.publicKeyPem, + requestMethod: request.method, + requestPath: actualPath, + }); + } catch { + throw new BrokerIngressError(401, "BROKER_INGRESS_AUTH_INVALID"); + } + + let bodyValue: unknown; + try { + bodyValue = JSON.parse(rawBody); + } catch { + throw new BrokerIngressError(400, "BROKER_INGRESS_INVALID_BODY"); + } + const invocationResult = safeParseRuntimeAgentRunInvocationValue(bodyValue); + if (!invocationResult.success) { + throw new BrokerIngressError(400, "BROKER_INGRESS_INVALID_BODY"); + } + const invocation = invocationResult.data; + if (invocation.run.runId !== expectedRunId) { + throw new BrokerIngressError(400, "CONTROL_PLANE_RUN_ID_MISMATCH"); + } + if ( + invocation.run.project.projectId !== options.projectId || + invocation.run.project.projectSlug !== options.audience || + claims.project_id !== invocation.run.project.projectId || + claims.aud !== invocation.run.project.projectSlug + ) throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); + const sourceError = verifyHostedRuntimeSourceBinding(options.boundSource, invocation.agentSource); + if (sourceError) throw new BrokerIngressError(sourceError.status, sourceError.errorCode); + + const inbound = buildRuntimeAgentControlPlaneStreamRequestFromInvocation(invocation); + const parsedInbound = getInternalAgentControlPlaneStreamRequestSchema().safeParse(inbound); + if (!parsedInbound.success) throw new BrokerIngressError(400, "BROKER_INGRESS_INVALID_BODY"); + const apiAuthToken = invocation.credentials?.authToken; + if (!apiAuthToken) throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); + if (containsForwardedAuthority(parsedInbound.data.forwardedProps)) { + throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); + } + + let authorization: TAuthorization | undefined; + try { + authorization = await options.authorizeScope({ + owner, + claims: Object.freeze({ ...claims }), + run: invocation.run, + authorization: inboundAuthorization, + apiAuthToken, + runEventToken, + }); + } catch { + throw new BrokerIngressError(500, "BROKER_INGRESS_SCOPE_FAILED"); + } + if (authorization === undefined) { + throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); + } + + const executorValue = snapshotExecutorValue({ + owner, + run: invocation.run, + ...(invocation.taskId ? { taskId: invocation.taskId } : {}), + agentSource: invocation.agentSource, + ...(invocation.agentConfig ? { agentConfig: invocation.agentConfig } : {}), + input: toRuntimeRunAgentInput(parsedInbound.data), + }); + for ( + const token of [ + inboundAuthorization, + apiAuthToken, + runEventToken, + invocation.credentials?.inferenceAuthToken, + ] + ) { + if (token && containsString(executorValue, token)) { + throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); + } + } + return { + privateAuthority: Object.freeze({ + owner, + claims: Object.freeze({ ...claims }), + inboundAuthorization, + apiAuthToken, + runEventToken, + ...(invocation.credentials?.inferenceAuthToken + ? { inferenceAuthToken: invocation.credentials.inferenceAuthToken } + : {}), + authorization, + rawBody, + }), + executor: executorValue as unknown as BrokerRuntimeAgentExecutorInput, + }; +} + +function snapshotExecutorValue(value: unknown) { + const snapshot = snapshotBoundedJsonValue(value); + if (!snapshot.success) throw new BrokerIngressError(400, "BROKER_INGRESS_INVALID_BODY"); + return snapshot.value; +} + +function containsForwardedAuthority(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some(containsForwardedAuthority); + for (const [key, entry] of Object.entries(value)) { + if (forbiddenForwardedAuthorityFields.has(key.replace(/[-_]/g, "").toLowerCase())) return true; + if (containsForwardedAuthority(entry)) return true; + } + return false; +} + +function containsString(value: unknown, expected: string): boolean { + if (value === expected) return true; + if (!value || typeof value !== "object") return false; + return Array.isArray(value) + ? value.some((entry) => containsString(entry, expected)) + : Object.values(value).some((entry) => containsString(entry, expected)); +} diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts new file mode 100644 index 0000000000..7606e5fb41 --- /dev/null +++ b/src/agent/service/managed-broker-handler.test.ts @@ -0,0 +1,351 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createControlPlaneSignature } from "#veryfront/server/handlers/request/internal-agent-run.test-helpers.ts"; +import type { + ManagedExecutorRuntime, + ManagedExecutorStartInput, +} from "../hosted/managed-executor-broker.ts"; +import { createManagedBrokerHandler } from "./managed-broker-handler.ts"; +import { ExecutorAgentError } from "../hosted/executor-agent-schema.ts"; + +const projectId = "00000000-0000-4000-8000-000000000005"; +const userId = "00000000-0000-4000-8000-000000000006"; +const path = "/api/control-plane/runs/run-1/stream"; + +async function request(signal?: AbortSignal) { + const body = JSON.stringify({ + run: { + agentServiceId: "service-1", + agentId: "builder", + conversationId: "00000000-0000-4000-8000-000000000001", + runId: "run-1", + messageId: "00000000-0000-4000-8000-000000000002", + inputAnchorMessageId: "00000000-0000-4000-8000-000000000003", + requestedByUserId: userId, + project: { projectId, projectSlug: "demo-project", runtimeTargetKind: "main_branch" }, + }, + messages: [], + tools: [], + context: [], + agentSource: { type: "release", releaseId: "release-1" }, + credentials: { authToken: "api-token", inferenceAuthToken: "inference-token" }, + }); + const signed = await createControlPlaneSignature(body, { + audience: "demo-project", + projectId, + requestId: "run-1", + requestPath: path, + }); + return { + publicKeyPem: signed.publicKeyPem, + request: new Request(`https://broker.test${path}`, { + method: "POST", + headers: { + authorization: "Bearer broker-token", + "x-veryfront-control-plane-jws": signed.jws, + "x-veryfront-run-event-token": "event-token", + }, + body, + signal, + }), + }; +} + +function runtimeFixture(streamFailure = false) { + const release = Promise.withResolvers(); + const settled = Promise.withResolvers(); + const acceptKinds: string[] = []; + const closeReasons: string[] = []; + let streamCalls = 0; + const runtime: ManagedExecutorRuntime = { + definition: { id: "builder", name: "Builder", description: "Builds", instructions: "Work" }, + modelId: "veryfront-cloud/openai/synthetic", + runtimeKind: "framework", + accepted: false, + settled: settled.promise, + runOwned: (operation) => operation(), + accept(ownership) { + acceptKinds.push(ownership.kind); + Object.defineProperty(runtime, "accepted", { value: true }); + }, + close(reason = "canceled") { + closeReasons.push(reason); + settled.resolve(); + return Promise.resolve({ reason, release: "released" }); + }, + agent: { + async stream() { + streamCalls++; + return { + steps: Promise.resolve([]), + toUIMessageStream: async function* () { + await release.promise; + if (streamFailure) throw new Error("synthetic stream failure"); + yield { type: "start", messageId: "assistant-message" } as const; + }, + }; + }, + }, + }; + return { + runtime, + release: release.resolve, + acceptKinds, + closeReasons, + get streamCalls() { + return streamCalls; + }, + }; +} + +async function handler( + mode: "detached" | "sse", + options: { + signal?: AbortSignal; + failStart?: boolean; + admitBeforeFailure?: boolean; + waitForPrepare?: boolean; + waitForAuthorization?: boolean; + throwingObserver?: boolean; + streamFailure?: boolean; + missingOutput?: boolean; + waitForOutput?: boolean; + startError?: Error; + } = {}, +) { + const first = await request(options.signal); + const fixture = runtimeFixture(options.streamFailure); + let prepareCalls = 0; + let brokerStarts = 0; + let cleanupCalls = 0; + const outputChunks: string[] = []; + const outputFinishes: boolean[] = []; + const outputRelease = Promise.withResolvers(); + let prepareSignal: AbortSignal | undefined; + const prepareEntered = Promise.withResolvers(); + const prepareRelease = Promise.withResolvers(); + const authorizationEntered = Promise.withResolvers(); + const authorizationRelease = Promise.withResolvers(); + const admitted = Promise.withResolvers(); + const managed = createManagedBrokerHandler({ + responseMode: mode, + broker: { + start: (start, lifecycle) => { + brokerStarts++; + prepareSignal = start.session.preparationSignal; + if (options.admitBeforeFailure) lifecycle?.onAdmitted?.(admitted.promise); + return options.failStart + ? Promise.reject(options.startError ?? new Error("synthetic start failure")) + : Promise.resolve(fixture.runtime); + }, + }, + resolveIngressOptions: () => ({ + publicKeyPem: first.publicKeyPem, + audience: "demo-project", + projectId, + expectedSurface: "studio", + boundSource: { type: "release", releaseId: "release-1" }, + expectedOwner: { scopeKind: "project", projectId }, + authorizeScope: async () => { + authorizationEntered.resolve(); + if (options.waitForAuthorization) await authorizationRelease.promise; + return { userId }; + }, + }), + prepare: async ({ signal }) => { + prepareCalls++; + prepareSignal = signal; + prepareEntered.resolve(); + if (options.waitForPrepare) await prepareRelease.promise; + return { + start: { session: {} } as ManagedExecutorStartInput, + messages: [], + executionSignal: new AbortController().signal, + output: options.missingOutput ? undefined : { + async write(chunk: { type: string }) { + outputChunks.push(chunk.type); + }, + async finish(outcome: { completed: boolean }) { + outputFinishes.push(outcome.completed); + if (options.waitForOutput) await outputRelease.promise; + }, + }, + cleanup: async () => { + cleanupCalls++; + }, + }; + }, + onExecutionError: options.throwingObserver + ? () => { + throw new Error("observer"); + } + : undefined, + }); + return { + first, + fixture, + managed, + admitted: admitted.resolve, + prepareEntered: prepareEntered.promise, + releasePrepare: prepareRelease.resolve, + authorizationEntered: authorizationEntered.promise, + releaseAuthorization: authorizationRelease.resolve, + outputChunks, + outputFinishes, + releaseOutput: outputRelease.resolve, + get prepareCalls() { + return prepareCalls; + }, + get brokerStarts() { + return brokerStarts; + }, + get cleanupCalls() { + return cleanupCalls; + }, + get prepareSignal() { + return prepareSignal; + }, + }; +} + +describe("managed broker handler", () => { + it("preserves typed executor failure statuses on both response modes", async () => { + for (const mode of ["detached", "sse"] as const) { + const f = await handler(mode, { + failStart: true, + startError: new ExecutorAgentError("INSUFFICIENT_CREDITS"), + }); + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 402); + assertEquals(await response.json(), { errorCode: "INSUFFICIENT_CREDITS" }); + await f.managed.close(); + } + }); + it("requires a durable output writer before allocating a detached run", async () => { + const f = await handler("detached", { missingOutput: true }); + try { + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 500); + assertEquals(f.brokerStarts, 0); + } finally { + f.fixture.release(); + await f.managed.close(); + } + }); + + it("persists detached output and retains retirement until the final write settles", async () => { + const f = await handler("detached", { waitForOutput: true }); + try { + assertEquals((await f.managed.handle(f.first.request)).status, 202); + f.fixture.release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(f.outputChunks, ["start"]); + assertEquals(f.outputFinishes, [true]); + assertEquals(f.fixture.closeReasons, []); + assertEquals(f.managed.active, 1); + } finally { + f.releaseOutput(); + f.fixture.release(); + await f.managed.close(); + } + assertEquals(f.cleanupCalls, 1); + }); + it("transfers detached ownership before 202 and prevents duplicate allocation", async () => { + const f = await handler("detached"); + const duplicateRequest = f.first.request.clone(); + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 202); + assertEquals(await response.json(), { accepted: true, duplicate: false }); + assertEquals(f.fixture.acceptKinds, ["execution"]); + const duplicate = await f.managed.handle(duplicateRequest); + assertEquals(await duplicate.json(), { accepted: true, duplicate: true }); + assertEquals(f.prepareCalls, 1); + f.fixture.release(); + await f.managed.close(); + assertEquals(f.fixture.closeReasons, ["completed"]); + }); + + it("preserves request-owned SSE and releases only after response completion", async () => { + const f = await handler("sse"); + const duplicateRequest = f.first.request.clone(); + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "text/event-stream; charset=utf-8"); + assertEquals(f.fixture.acceptKinds, ["request"]); + assertEquals(f.managed.active, 1); + const duplicate = await f.managed.handle(duplicateRequest); + assertEquals(duplicate.status, 409); + assertEquals(f.prepareCalls, 1); + f.fixture.release(); + await response.text(); + await f.managed.close(); + assertEquals(f.fixture.closeReasons, ["completed"]); + assertEquals(f.managed.active, 0); + }); + + it("releases failed setup reservations and maps the error without diagnostics", async () => { + const f = await handler("detached", { failStart: true }); + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 500); + assertEquals(await response.json(), { errorCode: "BROKER_EXECUTION_SETUP_FAILED" }); + assertEquals(f.managed.active, 0); + assertEquals(f.cleanupCalls, 1); + }); + + it("retains admitted setup failure and cleanup until actual session settlement", async () => { + const f = await handler("detached", { failStart: true, admitBeforeFailure: true }); + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 500); + assertEquals(f.managed.active, 1); + assertEquals(f.cleanupCalls, 0); + f.admitted(); + await f.managed.close(); + assertEquals(f.cleanupCalls, 1); + assertEquals(f.managed.active, 0); + }); + + it("does not admit a prepared run after handler closure during preparation", async () => { + const f = await handler("detached", { waitForPrepare: true }); + const response = f.managed.handle(f.first.request); + await f.prepareEntered; + const closing = f.managed.close(); + assertEquals(f.prepareSignal?.aborted, true); + f.releasePrepare(); + assertEquals((await response).status, 503); + await closing; + assertEquals(f.brokerStarts, 0); + assertEquals(f.cleanupCalls, 1); + }); + + it("does not admit a late authorization result after handler closure", async () => { + const f = await handler("detached", { waitForAuthorization: true }); + const response = f.managed.handle(f.first.request); + await f.authorizationEntered; + await f.managed.close(); + f.releaseAuthorization(); + assertEquals((await response).status, 503); + assertEquals(f.prepareCalls, 0); + assertEquals(f.brokerStarts, 0); + }); + + it("closes request-owned SSE as cancelled after request abort", async () => { + const controller = new AbortController(); + const f = await handler("sse", { signal: controller.signal }); + const response = await f.managed.handle(f.first.request); + controller.abort(); + f.fixture.release(); + await response.text(); + await f.managed.close(); + assertEquals(f.fixture.closeReasons, ["canceled"]); + }); + + it("shields throwing detached execution observers", async () => { + const f = await handler("detached", { streamFailure: true, throwingObserver: true }); + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 202); + f.fixture.release(); + await f.managed.close(); + assertEquals(f.fixture.closeReasons, ["canceled"]); + }); +}); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts new file mode 100644 index 0000000000..a92b539fdc --- /dev/null +++ b/src/agent/service/managed-broker-handler.ts @@ -0,0 +1,314 @@ +import type { HostedChatRuntimeStreamInput } from "../hosted/chat-runtime-contract.ts"; +import type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; +import { ExecutorAgentError } from "../hosted/executor-agent-schema.ts"; +import { ExecutorRuntimePreparationError } from "../hosted/executor-runtime-prepare-schema.ts"; +import { ExecutorDiscoveryError } from "../hosted/executor-discovery-schema.ts"; +import { HostedServiceAuthError } from "./auth.ts"; +import { createAgUiChatUiTrackedResponse } from "../ag-ui/chat-ui-chunk-encoder.ts"; +import type { AgUiRuntimeRequest } from "../runtime/ag-ui-contract.ts"; +import type { + ManagedExecutorRuntime, + ManagedExecutorStartInput, +} from "../hosted/managed-executor-broker.ts"; +import { + BrokerIngressError, + type BrokerRuntimeAgentIngress, + type BrokerRuntimeAgentIngressOptions, + parseBrokerRuntimeAgentIngress, +} from "./broker-ingress.ts"; + +const runPath = /^\/api\/control-plane\/runs\/([A-Za-z0-9_-]{1,128})\/stream$/u; + +/** Trusted executor admission boundary with actual settlement notification. */ +export interface ManagedExecutorStarter { + start( + input: ManagedExecutorStartInput, + lifecycle?: { onAdmitted?(settled: Promise): void }, + ): Promise; +} + +/** Broker-owned output persistence for a detached run. */ +export interface ManagedBrokerOutput { + write(chunk: ChatUiMessageChunk): Promise; + /** Acknowledge all original queued writes, including cancellation/failure finalization. */ + finish(outcome: { completed: boolean; error?: unknown }): Promise; +} + +/** Handle signed run invocations with configured detached or request-owned SSE responses. */ +export function createManagedBrokerHandler(options: { + broker: ManagedExecutorStarter; + /** Trusted route configuration; never read from request data. */ + responseMode: "detached" | "sse"; + signal?: AbortSignal; + resolveIngressOptions(input: { + request: Request; + runId: string; + }): Omit, "expectedRunId">; + prepare(input: { + ingress: BrokerRuntimeAgentIngress; + signal: AbortSignal; + }): Promise<{ + start: ManagedExecutorStartInput; + messages: HostedChatRuntimeStreamInput["messages"]; + executionSignal: AbortSignal; + output?: ManagedBrokerOutput; + cleanup?: () => Promise; + }>; + onExecutionError?: (error: unknown, runId: string) => void; +}) { + const active = new Map>(); + const lifetime = new AbortController(); + let closed = false; + + async function handle(request: Request): Promise { + const match = runPath.exec(new URL(request.url).pathname); + if (request.method !== "POST" || !match) { + return Response.json({ errorCode: "BROKER_INGRESS_TARGET_MISMATCH" }, { status: 400 }); + } + if (closed || options.signal?.aborted) { + return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); + } + const runId = match[1]!; + try { + const signal = AbortSignal.any([ + request.signal, + lifetime.signal, + ...(options.signal ? [options.signal] : []), + ]); + const ingress = await parseBrokerRuntimeAgentIngress(request, { + ...options.resolveIngressOptions({ request, runId }), + expectedRunId: runId, + signal, + }); + assertAvailable(closed, signal); + const runKey = managedRunKey(ingress); + if (active.has(runKey)) { + return options.responseMode === "detached" + ? Response.json({ accepted: true, duplicate: true }, { status: 202 }) + : Response.json({ errorCode: "BROKER_RUN_ALREADY_ACTIVE" }, { status: 409 }); + } + const reservation = Promise.withResolvers(); + active.set(runKey, reservation.promise); + let preparedCleanup: (() => Promise) | undefined; + let admissionSettled: Promise | undefined; + let retirement: Promise | undefined; + const release = () => { + if (active.get(runKey) === reservation.promise) active.delete(runKey); + reservation.resolve(); + }; + const retire = (settled: Promise = Promise.resolve()) => { + retirement ??= Promise.resolve().then(async () => { + await settled.catch(() => {}); + await preparedCleanup?.(); + }).finally(release); + return retirement; + }; + try { + const prepared = await options.prepare({ ingress, signal }); + preparedCleanup = prepared.cleanup; + assertAvailable(closed, signal); + if ( + options.responseMode === "detached" && + (typeof prepared.output?.write !== "function" || + typeof prepared.output?.finish !== "function") + ) { + throw new Error("Detached broker output persistence is required"); + } + const runtime = await options.broker.start({ + ...prepared.start, + session: { ...prepared.start.session, preparationSignal: signal }, + }, { + onAdmitted(settled) { + admissionSettled = settled; + }, + }); + admissionSettled = runtime.settled; + try { + runtime.accept( + options.responseMode === "detached" + ? { kind: "execution", signal: prepared.executionSignal } + : { kind: "request" }, + ); + } catch (error) { + await runtime.close("canceled").catch(() => {}); + void retire(runtime.settled).catch(() => {}); + throw error; + } + if (options.responseMode === "sse") { + try { + return await createSseResponse({ + runtime, + messages: prepared.messages, + requestSignal: request.signal, + runId, + threadId: ingress.executor.run.conversationId, + agentId: ingress.executor.run.agentId, + agUiInput: ingress.executor.input, + onSettled: () => retire(runtime.settled), + }); + } catch (error) { + await runtime.close("canceled").catch(() => {}); + void retire(runtime.settled).catch(() => {}); + throw error; + } + } + const execution = runDetached( + runtime, + prepared.messages, + prepared.executionSignal, + prepared.output!, + ) + .catch((error) => { + try { + options.onExecutionError?.(error, runId); + } catch { /* Observability cannot own execution settlement. */ } + }).finally(() => { + void retire(runtime.settled).catch(() => {}); + }); + void execution; + return Response.json({ accepted: true, duplicate: false }, { status: 202 }); + } catch (error) { + const retiring = retire(admissionSettled); + if (!admissionSettled) await retiring.catch(() => {}); + else void retiring.catch(() => {}); + throw error; + } + } catch (error) { + if (error instanceof BrokerIngressError) { + return Response.json({ errorCode: error.errorCode }, { status: error.status }); + } + if (error instanceof BrokerHandlerUnavailableError) { + return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); + } + const aborted = request.signal.aborted || options.signal?.aborted; + if (!aborted) { + if ( + error instanceof ExecutorAgentError || error instanceof ExecutorRuntimePreparationError || + error instanceof ExecutorDiscoveryError + ) { + return Response.json({ errorCode: error.code }, { status: error.status }); + } + if (error instanceof HostedServiceAuthError) { + return Response.json({ errorCode: error.errorCode }, { status: error.statusCode }); + } + } + return Response.json( + { errorCode: aborted ? "BROKER_INGRESS_ABORTED" : "BROKER_EXECUTION_SETUP_FAILED" }, + { status: aborted ? 499 : 500 }, + ); + } + } + + async function close(): Promise { + closed = true; + lifetime.abort(); + await Promise.allSettled([...active.values()]); + } + + return { + handle, + close, + get active() { + return active.size; + }, + }; +} + +class BrokerHandlerUnavailableError extends Error {} + +function assertAvailable(closed: boolean, signal: AbortSignal): void { + if (closed) throw new BrokerHandlerUnavailableError(); + signal.throwIfAborted(); +} + +function managedRunKey(ingress: BrokerRuntimeAgentIngress): string { + const owner = ingress.executor.owner; + const ownerKey = owner.scopeKind === "project" + ? `project:${owner.projectId}` + : `global:${owner.serviceName}`; + return `${ownerKey}:${ingress.executor.run.project.projectId}:${ingress.executor.run.runId}`; +} + +async function createSseResponse(input: { + runtime: ManagedExecutorRuntime; + messages: HostedChatRuntimeStreamInput["messages"]; + requestSignal: AbortSignal; + runId: string; + threadId: string; + agentId: string; + agUiInput: AgUiRuntimeRequest; + onSettled(): Promise; +}): Promise { + const result = await input.runtime.agent.stream({ + messages: input.messages, + abortSignal: input.requestSignal, + }); + const source = result.toUIMessageStream(); + const completion = Promise.withResolvers(); + let natural = false; + let cleanup: Promise | undefined; + const finish = (reason: "completed" | "canceled") => { + cleanup ??= Promise.resolve().then(async () => { + await input.runtime.close(reason).catch(() => {}); + await input.runtime.settled; + await input.onSettled(); + }); + return cleanup; + }; + const agentUIStream = (async function* () { + try { + for await (const chunk of source) yield chunk; + natural = true; + } finally { + completion.resolve(); + } + })(); + return createAgUiChatUiTrackedResponse({ + agUiInput: input.agUiInput, + defaults: { runId: input.runId, threadId: input.threadId }, + agentId: input.agentId, + modelId: input.runtime.modelId, + execution: { + agentUIStream, + async fail() { + await finish("canceled"); + }, + async waitForFinish() { + await completion.promise; + await finish(natural && !input.requestSignal.aborted ? "completed" : "canceled"); + }, + }, + }); +} + +async function runDetached( + runtime: ManagedExecutorRuntime, + messages: HostedChatRuntimeStreamInput["messages"], + signal: AbortSignal, + output: ManagedBrokerOutput, +): Promise { + let completed = false; + try { + await runtime.runOwned(async () => { + let streamCompleted = false; + let failure: unknown; + try { + const result = await runtime.agent.stream({ messages, abortSignal: signal }); + for await (const chunk of result.toUIMessageStream()) await output.write(chunk); + streamCompleted = true; + } catch (error) { + failure = error; + throw error; + } finally { + await output.finish({ + completed: streamCompleted, + ...(failure === undefined ? {} : { error: failure }), + }); + } + }); + completed = true; + } finally { + await runtime.close(completed ? "completed" : "canceled").catch(() => {}); + await runtime.settled; + } +} diff --git a/src/agent/service/managed-broker.ts b/src/agent/service/managed-broker.ts new file mode 100644 index 0000000000..bb843c8925 --- /dev/null +++ b/src/agent/service/managed-broker.ts @@ -0,0 +1,28 @@ +/** Managed broker composition without project runtime or application imports. */ +export { + createManagedExecutorBroker, + type ManagedExecutorBrokerOptions, + type ManagedExecutorRuntime, + type ManagedExecutorStartInput, +} from "../hosted/managed-executor-broker.ts"; +export { createManagedBrokerPersistence } from "../hosted/managed-broker-persistence.ts"; +export { createHostedExecutorAllocatorClient } from "../hosted/executor-allocator-client.ts"; +export { + connectExecutorTransport, + type ConnectExecutorTransportOptions, +} from "../hosted/executor-node-transport.ts"; +export { + createManagedBrokerHandler, + type ManagedBrokerOutput, + type ManagedExecutorStarter, +} from "./managed-broker-handler.ts"; +export { + BrokerIngressError, + type BrokerIngressErrorCode, + type BrokerIngressScopeInput, + type BrokerRuntimeAgentExecutorInput, + type BrokerRuntimeAgentIngress, + type BrokerRuntimeAgentIngressOptions, + type BrokerRuntimeAgentPrivateAuthority, + parseBrokerRuntimeAgentIngress, +} from "./broker-ingress.ts"; diff --git a/tests/fixtures/executor-runtime-process.ts b/tests/fixtures/executor-runtime-process.ts index a2c79bb8ab..66deb496d1 100644 --- a/tests/fixtures/executor-runtime-process.ts +++ b/tests/fixtures/executor-runtime-process.ts @@ -1,13 +1,11 @@ -import "#veryfront/schemas/_test-setup.ts"; -import "#veryfront/skill/_test-setup.ts"; import { readFileSync } from "node:fs"; import process from "node:process"; -import { register } from "#veryfront/extensions/contracts.ts"; -import { EsbuildBundler, EsModuleLexer } from "../../extensions/ext-bundler-esbuild/src/index.ts"; -import { startExecutorRuntimeEntrypoint } from "#veryfront/agent/hosted/executor-runtime-entrypoint.ts"; +import { + initializeExecutorRuntimeContracts, + startExecutorRuntimeEntrypoint, +} from "#veryfront/agent/hosted/executor-runtime-entrypoint.ts"; -register("Bundler", new EsbuildBundler()); -register("ModuleLexer", new EsModuleLexer()); +await initializeExecutorRuntimeContracts(); // Synthetic allocation key arrives on a private pipe; no broker environment or // HTTP data is inherited by this executor process. diff --git a/tests/integration/agent/executor-allocator-client.test.ts b/tests/integration/agent/executor-allocator-client.test.ts index 0ca931a235..d775daa26e 100644 --- a/tests/integration/agent/executor-allocator-client.test.ts +++ b/tests/integration/agent/executor-allocator-client.test.ts @@ -123,6 +123,23 @@ if ("Deno" in globalThis || "Bun" in globalThis) { } else { const tls = certificate(); describe("executor allocator HTTPS client", () => { + it("accepts native Node with a Deno compatibility namespace", () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "Deno"); + Object.defineProperty(globalThis, "Deno", { + configurable: true, + value: { version: { deno: "compatibility" } }, + }); + try { + const client = createHostedExecutorAllocatorClient({ + baseUrl: "https://allocator.example.test", + readBrokerToken: () => Promise.resolve("synthetic-token"), + }); + assertEquals(typeof client.allocate, "function"); + } finally { + if (original) Object.defineProperty(globalThis, "Deno", original); + else Reflect.deleteProperty(globalThis, "Deno"); + } + }); it("retains allocator DNS work until the native lookup settles", async () => { if (process.env.VF_EXECUTOR_DNS_RETIREMENT_TEST === "1") { let nativeWorkDone = false; diff --git a/tests/integration/agent/managed-broker-imports.test.ts b/tests/integration/agent/managed-broker-imports.test.ts new file mode 100644 index 0000000000..02b04652ad --- /dev/null +++ b/tests/integration/agent/managed-broker-imports.test.ts @@ -0,0 +1,71 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; + +type Module = { specifier: string; dependencies?: { code?: { specifier: string } }[] }; +type Graph = { roots: string[]; modules: Module[] }; +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const forbidden = [ + "/src/config/loader.ts", + "/src/agent/factory.ts", + "/src/tool/factory.ts", + "/src/agent/project/agent-runtime.ts", + "/src/agent/service/routes.ts", + "/src/agent/hosted/cloud-agent-config.ts", + "/src/agent/hosted/default-chat-runtime.ts", + "/src/agent/hosted/executor-discovery-node.ts", + "/src/server/runtime-handler/index.ts", + "/src/agent/hosted/executor-runtime-entrypoint.ts", + "/src/agent/hosted/executor-runtime-facades.ts", +]; + +for ( + const entry of [ + "src/agent/hosted/managed-executor-broker.ts", + "src/agent/service/broker-ingress.ts", + "src/agent/service/managed-broker-handler.ts", + "src/agent/service/managed-broker.ts", + ] +) { + it(`keeps project runtime imports out of ${entry}`, { timeout: 30_000 }, async () => { + const child = spawn(typeof Deno === "undefined" ? "deno" : Deno.execPath(), [ + "info", + "--frozen", + "--json", + entry, + ], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + let diagnostics = ""; + child.stdout.on("data", (chunk) => output += chunk); + child.stderr.on("data", (chunk) => diagnostics += chunk); + const timer = setTimeout(() => child.kill(), 25_000); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assertEquals(code, 0, diagnostics); + const graph: Graph = JSON.parse(output); + const modules = new Map(graph.modules.map((module) => [module.specifier, module])); + const visited = new Set(); + const pending = [...graph.roots]; + while (pending.length) { + const specifier = pending.shift()!; + if (visited.has(specifier)) continue; + visited.add(specifier); + // Include statically resolvable dynamic imports; omit erased type edges. + for (const dependency of modules.get(specifier)?.dependencies ?? []) { + if (dependency.code) pending.push(dependency.code.specifier); + } + } + assertEquals( + [...visited].filter((specifier) => forbidden.some((path) => specifier.endsWith(path))), + [], + ); + } finally { + clearTimeout(timer); + child.kill(); + } + }); +} From 755399e1ba77b360d73dc80fe7861cd6b54d526d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:27:35 +0200 Subject: [PATCH 079/194] test(agent): cover lifecycle and grant intrinsic tampering --- .../executor-runtime-private-facades.test.ts | 49 ++++++++++++ ...executor-runtime-private-lifecycle.test.ts | 75 ++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index c1141d6765..78bf6572ea 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -7,6 +7,10 @@ import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agen import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; import type { HostToolSet } from "#veryfront/tool"; +import { + resolveHostedRuntimeAllowedProviderTools, + resolveHostedRuntimeAllowedTools, +} from "#veryfront/agent/hosted/runtime-request-config.ts"; const binding = { allocationId: "reflection-allocation", @@ -17,6 +21,51 @@ const source = { type: "release", releaseId: "synthetic-release" } as const; const modelId = "veryfront-cloud/openai/gpt-5.4"; describe("private executor facades", () => { + it("preserves authored selectors when project code replaces array selection methods", () => { + const tools = ["visible"]; + const delegates = ["helper"]; + const originalIterator = Array.prototype[Symbol.iterator]; + const originalMap = Array.prototype.map; + const originalFilter = Array.prototype.filter; + let configured: string[] | undefined; + let requested: string[] | undefined; + let provider: string[] | undefined; + try { + Array.prototype[Symbol.iterator] = function () { + return originalIterator.call(this === tools ? ["hidden"] : this); + }; + Array.prototype.map = function (callback, thisArg) { + const mapped = this === delegates ? ["hidden"] : originalMap.call(this, callback, thisArg); + return mapped as ReturnType[]; + }; + Array.prototype.filter = function () { + return this; + }; + const config = { + configuredTools: tools, + configuredDelegates: delegates, + configuredSkills: [], + requestedTools: undefined, + }; + configured = resolveHostedRuntimeAllowedTools(config); + requested = resolveHostedRuntimeAllowedTools({ + ...config, + requestedTools: ["visible", "hidden"], + }); + provider = resolveHostedRuntimeAllowedProviderTools({ + configuredProviderTools: ["visible"], + requestedTools: ["visible", "hidden"], + }); + } finally { + Array.prototype[Symbol.iterator] = originalIterator; + Array.prototype.map = originalMap; + Array.prototype.filter = originalFilter; + } + assertEquals(configured, ["visible", "agent_helper"]); + assertEquals(requested, ["visible"]); + assertEquals(provider, ["visible"]); + }); + it("keeps ungranted private facades out of project-controlled reflection hooks", async () => { const originalEntries = Object.entries; const originalSetHas = Set.prototype.has; diff --git a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts index d248d513c5..f3d472f31d 100644 --- a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts +++ b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts @@ -2,11 +2,67 @@ import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { agent } from "#veryfront/agent/factory.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import type { RuntimeToolFilterConfig } from "#veryfront/agent/runtime/runtime-tool-config.ts"; import { executorAgentFailureCode } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; describe("executor runtime private lifecycle", () => { + it("retains original producer completion when project code replaces promise latches", async () => { + const entered = Promise.withResolvers(); + const unblock = Promise.withResolvers(); + const finalizationReleased = Promise.withResolvers(); + let completion: Promise | undefined; + let completed = false; + const config: RuntimeToolFilterConfig = { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + __vfProviderReplayCheckpointTurnFailed: async () => { + entered.resolve(); + await unblock.promise; + finalizationReleased.resolve(); + }, + }; + const runtime = new AgentRuntime("synthetic-runtime", config, { + resolveModelRuntime: () => ({ + provider: "openai", + modelId: "gpt-5.4", + doGenerate: () => Promise.reject(new Error("Unexpected generate")), + doStream: () => Promise.reject(new Error("Synthetic stream failure")), + }), + onStreamCompletion: (pending) => { + completion = pending; + void pending.then(() => { + completed = true; + }); + }, + }); + const original = Promise.withResolvers; + try { + Promise.withResolvers = (() => ({ + promise: Promise.resolve(), + resolve: () => {}, + reject: () => {}, + })) as typeof original; + const stream = await runtime.stream([{ + id: "synthetic-message", + role: "user", + parts: [{ type: "text", text: "Synthetic input" }], + }]); + await entered.promise; + await stream.cancel(); + assert(completion); + assertEquals(completed, false); + } finally { + Promise.withResolvers = original; + unblock.resolve(); + await finalizationReleased.promise; + await completion; + } + assertEquals(completed, true); + }); + it("classifies private errors without exposing them to a replaced descriptor intrinsic", () => { const original = Object.getOwnPropertyDescriptor; const privateError = { code: "PERMISSION_DENIED", detail: "Synthetic private detail" }; @@ -25,7 +81,7 @@ describe("executor runtime private lifecycle", () => { assertEquals(exposures, 0); }); - it("releases prepared facades and discovery after project code replaces promise chaining", async () => { + it("releases prepared facades and discovery after project code replaces lifecycle methods", async () => { const binding = { allocationId: "lifecycle", invocationId: "lifecycle", generation: 1 }; const source = { type: "release", releaseId: "synthetic-release" } as const; const modelId = "veryfront-cloud/openai/gpt-5.4"; @@ -33,6 +89,7 @@ describe("executor runtime private lifecycle", () => { id: "coder", system: "Synthetic source instructions.", model: modelId, + maxSteps: 3, tools: {}, }); let facadeCleanups = 0; @@ -95,7 +152,17 @@ describe("executor runtime private lifecycle", () => { }, }); const originalThen = Promise.prototype.then; + const originalAbort = AbortController.prototype.abort; + const originalMin = Math.min; + let stepLimitOverrides = 0; try { + Math.min = (...values) => { + if (values.length === 3 && values[0] === 5 && values[1] === 3 && values[2] === 5) { + stepLimitOverrides++; + return 100; + } + return originalMin(...values); + }; const operation = owner.operations.get("runtime.prepare"); assert(operation?.mode === "unary"); const result = await operation.handle({ agentId: "coder" }, { @@ -105,11 +172,17 @@ describe("executor runtime private lifecycle", () => { }); assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); Promise.prototype.then = (() => Promise.resolve()) as typeof originalThen; + AbortController.prototype.abort = () => {}; await owner.close(); } finally { Promise.prototype.then = originalThen; + AbortController.prototype.abort = originalAbort; + Math.min = originalMin; await owner.close(); } + assertEquals(owner.signal.aborted, true); + assertEquals(stepLimitOverrides, 0); + assertEquals(discovery.signal.aborted, true); assertEquals(facadeCleanups, 1); assertEquals(discoveryCleanups, 1); await owner.settled; From 13bfb746835400943b3bac1efca4a91a087bec37 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:30:50 +0200 Subject: [PATCH 080/194] fix(agent): keep skill selector ceilings outside mutable helpers --- src/agent/hosted/executor-runtime-prepare.ts | 2 +- src/agent/hosted/runtime-essential-tools.ts | 12 ++++++++---- src/agent/hosted/runtime-request-config.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index bc49b14b6e..34920b47a5 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -457,7 +457,7 @@ export function createExecutorRuntimePreparation(input: Options) { ), availableSkillIds: skills.allowedSkillIds, configDerivedSelector: request.allowedToolNames === undefined && - !(definition.tools === true && Boolean(definition.deniedTools?.length)), + !(definition.tools === true && (definition.deniedTools?.length ?? 0) > 0), }); allowedToolNames = intersectNames( normalizeToolNames(grant.allowedToolNames), diff --git a/src/agent/hosted/runtime-essential-tools.ts b/src/agent/hosted/runtime-essential-tools.ts index 80de6cd2d4..572dd8d50e 100644 --- a/src/agent/hosted/runtime-essential-tools.ts +++ b/src/agent/hosted/runtime-essential-tools.ts @@ -56,7 +56,8 @@ export function resolveHostedRuntimeAllowedToolNames( } const resolvedToolNames = createPrivateSet(localToolNames); - for (const toolName of EMPTY_SKILL_MANIFEST_TOOL_NAMES) { + for (let index = 0; index < EMPTY_SKILL_MANIFEST_TOOL_NAMES.length; index++) { + const toolName = EMPTY_SKILL_MANIFEST_TOOL_NAMES[index]!; resolvedToolNames.delete(toolName); } return resolvedToolNames; @@ -69,7 +70,8 @@ export function resolveHostedRuntimeAllowedToolNames( const resolvedToolNames = createPrivateSet(allowedToolNames); if (hasKnownSkillManifest && !hasAuthorizedSkills) { - for (const toolName of EMPTY_SKILL_MANIFEST_TOOL_NAMES) { + for (let index = 0; index < EMPTY_SKILL_MANIFEST_TOOL_NAMES.length; index++) { + const toolName = EMPTY_SKILL_MANIFEST_TOOL_NAMES[index]!; resolvedToolNames.delete(toolName); } } @@ -82,7 +84,8 @@ export function resolveHostedRuntimeAllowedToolNames( (resolvedToolNames.size > 0 || input.configDerivedSelector) && (!hasKnownSkillManifest || hasAuthorizedSkills) ) { - for (const toolName of SKILL_RUNTIME_TOOL_NAMES) { + for (let index = 0; index < SKILL_RUNTIME_TOOL_NAMES.length; index++) { + const toolName = SKILL_RUNTIME_TOOL_NAMES[index]!; if (localToolNames.has(toolName)) { resolvedToolNames.add(toolName); } @@ -94,7 +97,8 @@ export function resolveHostedRuntimeAllowedToolNames( // tools or declares a non-empty set, while a request- or delegation-derived // allowlist never has delegation appended to it. if (input.configDerivedSelector && hasAuthorizedSkills) { - for (const toolName of SKILL_DELEGATION_TOOL_NAMES) { + for (let index = 0; index < SKILL_DELEGATION_TOOL_NAMES.length; index++) { + const toolName = SKILL_DELEGATION_TOOL_NAMES[index]!; if (localToolNames.has(toolName)) { resolvedToolNames.add(toolName); } diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 48600b8637..f2b47230bc 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -272,7 +272,7 @@ export function resolveHostedRuntimeRequestConfig( input.agentConfig.model, ); const failClosedUnrestrictedToolDenials = input.agentConfig.tools === true && - Boolean(input.agentConfig.deniedTools?.length); + (input.agentConfig.deniedTools?.length ?? 0) > 0; return { effectiveRuntimeOverrides, From 05552043982e68f8f9560644dedc27aa2ab50207 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 16:35:51 +0200 Subject: [PATCH 081/194] feat(agent): preserve direct hosted ingress at the broker boundary --- docs/api-reference/veryfront/agent.md | 33 ++- src/agent/service/managed-broker.ts | 7 + .../service/managed-hosted-ingress.test.ts | 155 ++++++++++ src/agent/service/managed-hosted-ingress.ts | 270 ++++++++++++++++++ 4 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 src/agent/service/managed-hosted-ingress.test.ts create mode 100644 src/agent/service/managed-hosted-ingress.ts diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 084ca7275c..9b44f18049 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -2003,6 +2003,8 @@ import { | `createManagedBrokerPersistence` | Create exact-run API persistence callbacks while retaining credentials in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | | `createManagedExecutorBroker` | Compose an executor pool with authenticated installation and operation gates. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | | `parseBrokerRuntimeAgentIngress` | Read and verify a signed invocation once before constructing executor-safe data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `parseManagedAgUiAgentIngress` | Parse the trusted broker's direct request-owned AG-UI ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `parseManagedDurableAgentIngress` | Parse the trusted broker's direct canonical durable-run ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | #### Classes @@ -2012,17 +2014,20 @@ import { #### Types -| Name | Description | Source | -| ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | -| `ManagedBrokerOutput` | Broker-owned output persistence for a detached run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | -| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | -| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| Name | Description | Source | +| ------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | +| `ManagedAgentBrokerIngressAuthority` | Private parsed request and credentials available only to trusted broker preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedAgentExecutorRequest` | Detached bounded application data, without HTTP objects or broker credentials. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedAgentIngressResult` | Preserve the distinct durable and direct AG-UI ingress contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedBrokerOutput` | Broker-owned output persistence for a detached run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | diff --git a/src/agent/service/managed-broker.ts b/src/agent/service/managed-broker.ts index bb843c8925..e9afaa3c16 100644 --- a/src/agent/service/managed-broker.ts +++ b/src/agent/service/managed-broker.ts @@ -26,3 +26,10 @@ export { type BrokerRuntimeAgentPrivateAuthority, parseBrokerRuntimeAgentIngress, } from "./broker-ingress.ts"; +export { + type ManagedAgentBrokerIngressAuthority, + type ManagedAgentExecutorRequest, + type ManagedAgentIngressResult, + parseManagedAgUiAgentIngress, + parseManagedDurableAgentIngress, +} from "./managed-hosted-ingress.ts"; diff --git a/src/agent/service/managed-hosted-ingress.test.ts b/src/agent/service/managed-hosted-ingress.test.ts new file mode 100644 index 0000000000..94720ed430 --- /dev/null +++ b/src/agent/service/managed-hosted-ingress.test.ts @@ -0,0 +1,155 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + parseManagedAgUiAgentIngress, + parseManagedDurableAgentIngress, +} from "./managed-hosted-ingress.ts"; + +const projectId = "10000000-1000-4000-8000-100000000005"; +const conversationId = "10000000-1000-4000-8000-100000000001"; +const messageId = "10000000-1000-4000-8000-100000000002"; + +function authenticate() { + return Promise.resolve({ userId: "user-1", authToken: "broker-auth-secret" }); +} + +function verifyProjectAccess() { + return Promise.resolve({ success: true as const, projectSlug: "demo" }); +} + +describe("managed agent ingress", () => { + it("separates durable broker authority from a detached executor request", async () => { + const request = new Request("https://agent.example.test/api/runs", { + method: "POST", + headers: { + "content-type": "application/json", + "X-Veryfront-Run-Event-Token": "run-event-secret", + "X-Veryfront-Inference-Token": "inference-secret", + }, + body: JSON.stringify({ + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "Hello" }] }], + context: { conversationId, projectId, branchId: "branch-1" }, + durableRootRun: { runId: "run_root_1", messageId }, + }), + }); + + const result = await parseManagedDurableAgentIngress(request, { + authenticate, + verifyProjectAccess, + verifyRunEventAppendToken: () => Promise.resolve(true), + }); + if (result instanceof Response) throw new Error("Expected managed durable ingress"); + + assertEquals(result.kind, "durable"); + assertEquals(result.executor.kind, "durable"); + assertEquals(result.executor.projectId, projectId); + assertEquals(result.executor.projectSlug, "demo"); + assertEquals(result.executor.durableRootRun, { runId: "run_root_1", messageId }); + assertEquals(result.executor.serverEnvelopeVerified, false); + assertEquals( + JSON.stringify(result), + JSON.stringify({ + kind: "durable", + broker: {}, + executor: result.executor, + }), + ); + + const serialized = JSON.stringify(result.executor); + for (const secret of ["broker-auth-secret", "run-event-secret", "inference-secret"]) { + assertEquals(serialized.includes(secret), false); + } + for (const forbiddenKey of ["authToken", "authorization", "rawRequest", "headers"]) { + assertEquals(forbiddenKey in result.executor, false); + } + + assertEquals(typeof result.broker.createInferenceModelResolver(), "function"); + assertEquals( + typeof result.broker.createRunEventWriterCapability({ + apiUrl: "https://api.example.test", + })?.mintChildRunEventWriterCapability, + "function", + ); + assertEquals(result.broker.getParsedRequest().authToken, "broker-auth-secret"); + + const parsed = result.broker.getParsedRequest(); + const originalText = parsed.messages[0]?.parts[0]; + if (originalText && typeof originalText === "object" && "text" in originalText) { + originalText.text = "mutated after projection"; + } + assertEquals(JSON.stringify(result.executor).includes("mutated after projection"), false); + }); + + it("preserves AG-UI validation shape and keeps unverified replay state out of the executor", async () => { + const invalid = await parseManagedAgUiAgentIngress( + new Request("https://agent.example.test/api/ag-ui", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ runId: "missing-thread-and-messages" }), + }), + { authenticate, verifyProjectAccess }, + ); + if (!(invalid instanceof Response)) throw new Error("Expected validation response"); + assertEquals(invalid.status, 400); + const invalidBody = await invalid.json(); + assertEquals((invalidBody as { errorCode?: unknown }).errorCode, "VALIDATION_ERROR"); + assertEquals(typeof (invalidBody as { message?: unknown }).message, "string"); + + const result = await parseManagedAgUiAgentIngress( + new Request("https://agent.example.test/api/ag-ui", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + threadId: "11111111-1111-4111-8111-111111111111", + runId: "run-1", + messages: [{ id: "u1", role: "user", content: "Hello" }], + tools: [], + context: [{ description: "veryfront.projectId", value: JSON.stringify(projectId) }], + forwardedProps: { + serverResolvedProviderReplayCheckpoints: { forged: "replay-secret" }, + }, + serverResolvedProviderReplayCheckpoints: { forged: "top-level-replay-secret" }, + }), + }), + { authenticate, verifyProjectAccess }, + ); + if (result instanceof Response) throw new Error("Expected managed AG-UI ingress"); + + assertEquals(result.kind, "ag-ui"); + assertEquals(result.executor.kind, "ag-ui"); + assertEquals(result.executor.agUi, { + threadId: "11111111-1111-4111-8111-111111111111", + runId: "run-1", + parentRunId: null, + tools: [], + context: [{ description: "veryfront.projectId", value: JSON.stringify(projectId) }], + }); + const serialized = JSON.stringify(result.executor); + assertEquals(serialized.includes("broker-auth-secret"), false); + assertEquals(serialized.includes("replay-secret"), false); + assertEquals(result.broker.createInferenceModelResolver(), undefined); + assertEquals( + result.broker.createRunEventWriterCapability({ apiUrl: "https://api.example.test" }), + undefined, + ); + }); + + it("authenticates AG-UI before reading the request body", async () => { + const request = new Request("https://agent.example.test/api/ag-ui", { + method: "POST", + headers: { "X-Veryfront-Run-Event-Token": "must-stay-broker-private" }, + body: "not json", + }); + const response = new Response("unauthenticated", { status: 401 }); + const result = await parseManagedAgUiAgentIngress(request, { + authenticate: (applicationRequest) => { + assertEquals(applicationRequest.headers.get("X-Veryfront-Run-Event-Token"), null); + return Promise.resolve(response); + }, + }); + + assertEquals(result, response); + assertEquals(request.bodyUsed, false); + }); +}); diff --git a/src/agent/service/managed-hosted-ingress.ts b/src/agent/service/managed-hosted-ingress.ts new file mode 100644 index 0000000000..d2338be2ba --- /dev/null +++ b/src/agent/service/managed-hosted-ingress.ts @@ -0,0 +1,270 @@ +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; +import { + buildParsedHostedAgUiRequest, + createHostedAgUiValidationErrorResponse, + type ParsedHostedAgUiRequest, +} from "../hosted/ag-ui-chat-request.ts"; +import { + createHostedRunEventWriterCapabilityForRequest, + type HostedRunEventWriterCapability, +} from "../hosted/child-run-event-writer-token.ts"; +import { + type ParsedHostedChatRequest, + parseHostedChatRequestFromRequest, + type ParseHostedChatRequestOptions, +} from "../hosted/chat-request-parser.ts"; +import { createHostedInferenceModelResolver } from "../hosted/inference-credential.ts"; +import type { AgentModelRuntimeResolver } from "../runtime/model-transport.ts"; +import { parseAgUiRuntimeRequestOrError } from "../runtime/ag-ui-contract.ts"; +import { isResponseLike } from "./response-like.ts"; + +export type ManagedAgentIngressKind = "durable" | "ag-ui"; + +export interface ManagedRunEventWriterCapabilityOptions { + apiUrl: string; + timeoutMs?: number; + fetch?: typeof globalThis.fetch; +} + +/** Private parsed request and credentials available only to trusted broker preparation. */ +export interface ManagedAgentBrokerIngressAuthority< + TRequest extends ParsedHostedChatRequest = ParsedHostedChatRequest, +> { + getParsedRequest(): TRequest; + createInferenceModelResolver(options?: { apiBaseUrl?: string }): + | AgentModelRuntimeResolver + | undefined; + createRunEventWriterCapability( + options: ManagedRunEventWriterCapabilityOptions, + ): HostedRunEventWriterCapability | undefined; +} + +export type ManagedAgentExecutorAgUiState = Readonly<{ + threadId: string; + runId: string; + parentRunId: string | null; + tools: JsonValue; + context: JsonValue; + state?: JsonValue; +}>; + +/** Detached bounded application data, without HTTP objects or broker credentials. */ +export type ManagedAgentExecutorRequest = Readonly<{ + protocolVersion: 1; + kind: ManagedAgentIngressKind; + agentId: string | null; + userId: string; + messages: JsonValue; + context: JsonValue; + projectId: string | null; + projectSlug: string | null; + conversationId: string | null; + parentRunId: string | null; + upstreamParentConversationId: string | null; + upstreamParentRunId: string | null; + spawnedFromToolCallId: string | null; + model: string | null; + allowDelegation: boolean | null; + forwardedProps: JsonValue; + runtimeOverrides: JsonValue; + durableRootRun: JsonValue; + persistLatestUserMessageBeforeDurableRun: boolean; + serverEnvelopeVerified: boolean; + serverResolvedIntegrationToolNames: JsonValue; + serverResolvedProviderReplayCheckpoints?: JsonValue; + agUi?: ManagedAgentExecutorAgUiState; +}>; + +export type ManagedDurableAgentIngressResult = Readonly<{ + kind: "durable"; + broker: ManagedAgentBrokerIngressAuthority; + executor: ManagedAgentExecutorRequest & { kind: "durable" }; +}>; + +export type ManagedAgUiAgentIngressResult = Readonly<{ + kind: "ag-ui"; + broker: ManagedAgentBrokerIngressAuthority; + executor: ManagedAgentExecutorRequest & { + kind: "ag-ui"; + agUi: ManagedAgentExecutorAgUiState; + }; +}>; + +/** Preserve the distinct durable and direct AG-UI ingress contracts. */ +export type ManagedAgentIngressResult = + | ManagedDurableAgentIngressResult + | ManagedAgUiAgentIngressResult; + +export type ParseManagedAgUiAgentIngressOptions = + & Pick< + ParseHostedChatRequestOptions, + "authenticate" | "verifyProjectAccess" + > + & { forwardedConfigNamespace?: string }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sanitizeForwardedProps(value: unknown): unknown { + if (!isRecord(value)) return null; + const sanitized: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (!key.startsWith("serverResolved")) sanitized[key] = entry; + } + const runtimeOverrides = sanitized.runtimeOverrides; + if ( + isRecord(runtimeOverrides) && Object.hasOwn(runtimeOverrides, "serverResolvedIntegrationTools") + ) { + const sanitizedRuntimeOverrides = Object.fromEntries( + Object.entries(runtimeOverrides).filter(([key]) => key !== "serverResolvedIntegrationTools"), + ); + if (Object.keys(sanitizedRuntimeOverrides).length > 0) { + sanitized.runtimeOverrides = sanitizedRuntimeOverrides; + } else { + delete sanitized.runtimeOverrides; + } + } + return Object.keys(sanitized).length > 0 ? sanitized : null; +} + +function boundedExecutorRequest(value: unknown): ManagedAgentExecutorRequest { + const snapshot = snapshotBoundedJsonValue(value); + if (!snapshot.success) { + throw new TypeError("Managed agent executor request must contain bounded JSON data"); + } + return snapshot.value as ManagedAgentExecutorRequest; +} + +function createBrokerAuthority( + parsedRequest: TRequest, +): ManagedAgentBrokerIngressAuthority { + const authority = Object.create(null) as ManagedAgentBrokerIngressAuthority; + Object.defineProperties(authority, { + getParsedRequest: { + enumerable: false, + value: () => parsedRequest, + }, + createInferenceModelResolver: { + enumerable: false, + value: (options?: { apiBaseUrl?: string }) => + createHostedInferenceModelResolver(parsedRequest, options), + }, + createRunEventWriterCapability: { + enumerable: false, + value: (options: ManagedRunEventWriterCapabilityOptions) => { + const runId = parsedRequest.durableRootRun?.runId; + return runId + ? createHostedRunEventWriterCapabilityForRequest(parsedRequest, { + ...options, + runId, + }) + : undefined; + }, + }, + }); + return Object.freeze(authority); +} + +function createExecutorRequest( + kind: ManagedAgentIngressKind, + parsedRequest: ParsedHostedChatRequest, + agUiInput?: ParsedHostedAgUiRequest["agUiInput"], +): ManagedAgentExecutorRequest { + const request = { + protocolVersion: 1, + kind, + agentId: parsedRequest.agentId ?? null, + userId: parsedRequest.userId, + messages: parsedRequest.messages, + context: parsedRequest.validatedContext, + projectId: parsedRequest.projectId, + projectSlug: parsedRequest.projectSlug ?? null, + conversationId: parsedRequest.conversationId ?? null, + parentRunId: parsedRequest.parentRunId ?? null, + upstreamParentConversationId: parsedRequest.upstreamParentConversationId ?? null, + upstreamParentRunId: parsedRequest.upstreamParentRunId ?? null, + spawnedFromToolCallId: parsedRequest.spawnedFromToolCallId ?? null, + model: parsedRequest.model ?? null, + allowDelegation: parsedRequest.allowDelegation ?? null, + forwardedProps: sanitizeForwardedProps(parsedRequest.forwardedProps), + runtimeOverrides: parsedRequest.runtimeOverrides ?? null, + durableRootRun: parsedRequest.durableRootRun ?? null, + persistLatestUserMessageBeforeDurableRun: + parsedRequest.persistLatestUserMessageBeforeDurableRun, + serverEnvelopeVerified: parsedRequest.serverEnvelopeVerified === true, + serverResolvedIntegrationToolNames: parsedRequest.serverResolvedIntegrationToolNames ?? [], + ...(parsedRequest.serverEnvelopeVerified === true && + Object.hasOwn(parsedRequest, "serverResolvedProviderReplayCheckpoints") + ? { + serverResolvedProviderReplayCheckpoints: + parsedRequest.serverResolvedProviderReplayCheckpoints, + } + : {}), + ...(agUiInput + ? { + agUi: { + threadId: agUiInput.threadId, + runId: agUiInput.runId, + parentRunId: agUiInput.parentRunId ?? null, + tools: agUiInput.tools, + context: agUiInput.context, + ...(Object.hasOwn(agUiInput, "state") && agUiInput.state !== undefined + ? { state: agUiInput.state } + : {}), + }, + } + : {}), + }; + return boundedExecutorRequest(request); +} + +/** Parse the trusted broker's direct canonical durable-run ingress. */ +export async function parseManagedDurableAgentIngress( + request: Request, + options: ParseHostedChatRequestOptions, +): Promise { + const parsedRequest = await parseHostedChatRequestFromRequest(request, options); + if (isResponseLike(parsedRequest)) return parsedRequest; + return Object.freeze({ + kind: "durable" as const, + broker: createBrokerAuthority(parsedRequest), + executor: createExecutorRequest("durable", parsedRequest) as + & ManagedAgentExecutorRequest + & { kind: "durable" }, + }); +} + +/** Parse the trusted broker's direct request-owned AG-UI ingress. */ +export async function parseManagedAgUiAgentIngress( + request: Request, + options: ParseManagedAgUiAgentIngressOptions, +): Promise { + const applicationRequest = createApplicationRequest(request); + const principal = await options.authenticate(applicationRequest); + if (isResponseLike(principal)) return principal; + + const agUiInput = await parseAgUiRuntimeRequestOrError(applicationRequest); + if (isResponseLike(agUiInput)) { + return await createHostedAgUiValidationErrorResponse(agUiInput); + } + + const parsedRequest = await buildParsedHostedAgUiRequest({ + agUiInput, + authToken: principal.authToken, + userId: principal.userId, + forwardedConfigNamespace: options.forwardedConfigNamespace, + verifyProjectAccess: options.verifyProjectAccess, + }); + if (isResponseLike(parsedRequest)) return parsedRequest; + + return Object.freeze({ + kind: "ag-ui" as const, + broker: createBrokerAuthority(parsedRequest), + executor: createExecutorRequest("ag-ui", parsedRequest, agUiInput) as + & ManagedAgentExecutorRequest + & { kind: "ag-ui"; agUi: ManagedAgentExecutorAgUiState }, + }); +} From ce3e86a392b0a123df500ef48fe7ec351b8f4c63 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:45:14 +0200 Subject: [PATCH 082/194] fix(agent): snapshot facade metadata and preserve cancellation listeners --- .../hosted/chat-runtime-tool-assembly.ts | 7 ++++- src/agent/hosted/executor-discovery.ts | 10 +++--- src/agent/hosted/executor-runtime-prepare.ts | 26 ++++++++++------ src/tool/host-tools.test.ts | 26 ++++++++++++++++ src/tool/host-tools.ts | 31 ++++++++++++++++--- 5 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 306c570fcf..f538cc3ce4 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -533,7 +533,12 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ) { const hostedWebFetchTool = postFormInputLocalTools.web_fetch; if (hostedWebFetchTool !== undefined) { - sortedLocalToolEntries[sortedLocalToolEntries.length] = ["web_fetch", hostedWebFetchTool]; + objectDefineProperty(sortedLocalToolEntries, sortedLocalToolEntries.length, { + value: ["web_fetch", hostedWebFetchTool], + enumerable: true, + configurable: true, + writable: true, + }); } } const sortedLocalTools = recordFromEntries( diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index c1d2593108..aabb8da0d9 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -32,6 +32,8 @@ import { const apply = Reflect.apply; const abortController = AbortController.prototype.abort; +const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; export interface ExecutorDiscoveryBackend { load(signal: AbortSignal): Promise; @@ -126,14 +128,14 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } }); void chain(closing, settled.resolve, settled.reject); - input.signal.removeEventListener("abort", onAbort); + apply(removeEventListener, input.signal, ["abort", onAbort]); apply(abortController, lifetime, []); return closing; } const onAbort = () => { void chain(close(), () => {}, () => {}); }; - input.signal.addEventListener("abort", onAbort, { once: true }); + apply(addEventListener, input.signal, ["abort", onAbort, { once: true }]); if (input.signal.aborted) onAbort(); async function discover() { @@ -234,7 +236,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut const onCancel = () => { void chain(close(), () => {}, () => {}); }; - context.signal.addEventListener("abort", onCancel, { once: true }); + apply(addEventListener, context.signal, ["abort", onCancel, { once: true }]); const work = chain(tail, async () => { if (context.signal.aborted || Date.now() >= context.deadline) { onCancel(); @@ -268,7 +270,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } return { ok: false, code }; } finally { - context.signal.removeEventListener("abort", onCancel); + apply(removeEventListener, context.signal, ["abort", onCancel]); } } diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 34920b47a5..6fe1ae09e6 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -85,6 +85,15 @@ const abortController = AbortController.prototype.abort; const abortSignalAny = AbortSignal.any; const AbortSignalConstructor = AbortSignal; const mathMin = Math.min; +const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; +const iteratorSymbol = Symbol.iterator; + +function combineSignals(...signals: AbortSignal[]): AbortSignal { + const inputs = createPrivateSet(signals); + objectDefineProperty(signals, iteratorSymbol, { value: () => inputs.values() }); + return apply(abortSignalAny, AbortSignalConstructor, [signals]) as AbortSignal; +} function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { const filtered: T[] = []; @@ -275,13 +284,13 @@ export function createExecutorRuntimePreparation(input: Options) { }); void chain(closing, settled.resolve, settled.reject); apply(abortController, lifetime, []); - input.discovery.signal.removeEventListener("abort", onDiscoveryAbort); + apply(removeEventListener, input.discovery.signal, ["abort", onDiscoveryAbort]); return closing; } const onDiscoveryAbort = () => { void chain(close(), () => {}, () => {}); }; - input.discovery.signal.addEventListener("abort", onDiscoveryAbort, { once: true }); + apply(addEventListener, input.discovery.signal, ["abort", onDiscoveryAbort, { once: true }]); if (input.discovery.signal.aborted) onDiscoveryAbort(); function requireFacades( @@ -438,7 +447,7 @@ export function createExecutorRuntimePreparation(input: Options) { definition, projectId: execution.projectId, branchId: execution.branchId, - signal: lifetime.signal, + signal: context.signal, }) : undefined; assertActive(); @@ -694,19 +703,16 @@ export function createExecutorRuntimePreparation(input: Options) { const cancel = () => { void chain(close(), () => {}, () => {}); }; - context.signal.addEventListener("abort", cancel, { once: true }); + apply(addEventListener, context.signal, ["abort", cancel, { once: true }]); preparation = prepare(request, { ...context, - signal: apply(abortSignalAny, AbortSignalConstructor, [[ - context.signal, - lifetime.signal, - ]]), + signal: combineSignals(context.signal, lifetime.signal), }); if (context.signal.aborted) cancel(); try { return await preparation; } finally { - context.signal.removeEventListener("abort", cancel); + apply(removeEventListener, context.signal, ["abort", cancel]); } } catch (error) { return { @@ -729,7 +735,7 @@ export function createExecutorRuntimePreparation(input: Options) { if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); yield* operation.handle(value, { ...context, - signal: apply(abortSignalAny, AbortSignalConstructor, [[context.signal, lifetime.signal]]), + signal: combineSignals(context.signal, lifetime.signal), }); }, }); diff --git a/src/tool/host-tools.test.ts b/src/tool/host-tools.test.ts index 3f495fe425..366ffe8cac 100644 --- a/src/tool/host-tools.test.ts +++ b/src/tool/host-tools.test.ts @@ -15,6 +15,32 @@ import type { RemoteToolSource, ToolExecutionContext, ToolSet } from "./types.ts const emptyJsonSchema = { type: "object" as const, properties: {} }; describe("tool/host-tools", () => { + it("ignores inherited optional metadata and preserves the original execution receiver", async () => { + let metadataReads = 0; + const definition: HostToolSet[string] = Object.create({ + get inputSchemaJson() { + metadataReads++; + return undefined; + }, + get mcp() { + metadataReads++; + return undefined; + }, + }, { + description: { value: "Synthetic tool", enumerable: true }, + inputSchema: { value: defineSchema((v) => v.object({}))(), enumerable: true }, + execute: { + value: function (this: HostToolSet[string]) { + return { originalReceiver: this === definition }; + }, + enumerable: true, + }, + }); + const tools = createToolsFromHostDefinitions({ synthetic: definition }); + assertEquals(metadataReads, 0); + assertEquals(await tools.synthetic?.execute({}), { originalReceiver: true }); + }); + it("materializes prototype-named tools as own data properties", async () => { const tools = createToolsFromHostDefinitions({ ["__proto__"]: { diff --git a/src/tool/host-tools.ts b/src/tool/host-tools.ts index 613048fd86..8692ba1971 100644 --- a/src/tool/host-tools.ts +++ b/src/tool/host-tools.ts @@ -9,6 +9,10 @@ const apply = Reflect.apply; const arrayIsArray = Array.isArray; const objectEntries = Object.entries; const objectDefineProperty = Object.defineProperty; +const objectCreate = Object.create; +const objectGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const objectHasOwn = Object.hasOwn; +const objectKeys = Object.keys; type HostToolExecute = { bivarianceHack: (input: unknown, options?: ToolExecutionContext) => Promise | unknown; @@ -83,6 +87,21 @@ function defaultToolCallId(toolName: string): string { return `${toolName}-${crypto.randomUUID()}`; } +function snapshotHostToolDefinition(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const descriptors = objectGetOwnPropertyDescriptors(value); + const snapshot: Record = objectCreate(null); + const keys = objectKeys(descriptors); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const descriptor = descriptors[key]; + if (descriptor && objectHasOwn(descriptor, "value")) { + objectDefineProperty(snapshot, key, { value: descriptor.value, enumerable: true }); + } + } + return snapshot; +} + function normalizeExecutionContext( toolName: string, context: ToolExecutionContext | undefined, @@ -120,11 +139,15 @@ export function createToolsFromHostDefinitions( const entry = entries[index]; if (entry === undefined) continue; const toolName = entry[0]; - const definition = entry[1]; + const originalDefinition = entry[1]; + const definition = snapshotHostToolDefinition(originalDefinition); if (!isHostToolDefinition(definition)) continue; const execute = async (input: unknown, context: ToolExecutionContext | undefined) => - await definition.execute(input, normalizeExecutionContext(toolName, context, options)); + await apply(definition.execute, originalDefinition, [ + input, + normalizeExecutionContext(toolName, context, options), + ]); try { let materializedTool: Tool | undefined; @@ -147,12 +170,12 @@ export function createToolsFromHostDefinitions( }); } if (materializedTool) { - const canonicalRemoteToolName = getRemoteToolProvenance(definition); + const canonicalRemoteToolName = getRemoteToolProvenance(originalDefinition); const toolWithRemoteProvenance = canonicalRemoteToolName ? markRemoteToolProvenance(materializedTool, canonicalRemoteToolName) : materializedTool; objectDefineProperty(tools, toolName, { - value: inheritTrustedHostToolProvenance(definition, toolWithRemoteProvenance), + value: inheritTrustedHostToolProvenance(originalDefinition, toolWithRemoteProvenance), enumerable: true, configurable: true, writable: true, From a742e615ab45675833e7ff1825447fccb395893a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 16:49:47 +0200 Subject: [PATCH 083/194] test(agent): cover facade metadata and cancellation listener hooks --- ...executor-runtime-private-lifecycle.test.ts | 68 +++++++++++++++ ...runtime-tool-provenance-intrinsics.test.ts | 87 +++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts index f3d472f31d..d44ebcec93 100644 --- a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts +++ b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts @@ -7,8 +7,76 @@ import type { RuntimeToolFilterConfig } from "#veryfront/agent/runtime/runtime-t import { executorAgentFailureCode } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; +import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; describe("executor runtime private lifecycle", () => { + it("propagates operation cancellation after project code replaces listener registration", async () => { + const binding = { allocationId: "listeners", invocationId: "listeners", generation: 1 }; + const source = { type: "release", releaseId: "synthetic-release" } as const; + const modelId = "veryfront-cloud/openai/gpt-5.4"; + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + let backendSignal: AbortSignal | undefined; + const discovery = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + signal: new AbortController().signal, + backend: { + load: (signal) => { + backendSignal = signal; + entered.resolve(); + return finish.promise; + }, + cleanup: () => Promise.resolve(), + }, + }); + const owner = createExecutorRuntimePreparation({ + binding, + source, + discovery, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, + }, + facades: { + hostTools: new Map(), + remoteToolSources: new Map(), + resolveModelRuntime: () => undefined, + cleanup: () => Promise.resolve(), + }, + }); + const original = EventTarget.prototype.addEventListener; + const controller = new AbortController(); + let preparing: JsonValue | Promise | undefined; + try { + EventTarget.prototype.addEventListener = () => {}; + const operation = owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + preparing = operation.handle({ agentId: "coder" }, { + binding, + signal: controller.signal, + deadline: Date.now() + 30_000, + }); + await entered.promise; + controller.abort(); + assertEquals(owner.signal.aborted, true); + assertEquals(backendSignal?.aborted, true); + } finally { + EventTarget.prototype.addEventListener = original; + finish.reject(new DOMException("Synthetic cancellation", "AbortError")); + await owner.close(); + await preparing; + } + }); + it("retains original producer completion when project code replaces promise latches", async () => { const entered = Promise.withResolvers(); const unblock = Promise.withResolvers(); diff --git a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts index a082f9edca..4a71657611 100644 --- a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts +++ b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts @@ -3,6 +3,7 @@ import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import { createToolsFromHostDefinitions } from "#veryfront/tool/host-tools.ts"; +import { prepareFacadedHostedChatRuntimeToolAssembly } from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; import { hasTrustedHostToolProvenance, inheritTrustedHostToolProvenance, @@ -21,6 +22,92 @@ import { } from "#veryfront/tool/remote-tool-provenance.ts"; describe("runtime tool provenance intrinsics", () => { + it("keeps fallback facade entries private from inherited array setters", async () => { + const definition = { + description: "Synthetic fetch tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }; + const original = Object.getOwnPropertyDescriptor(Array.prototype, "0"); + const defineProperty = Object.defineProperty; + let exposures = 0; + let names: string[] | undefined; + try { + defineProperty(Array.prototype, "0", { + configurable: true, + set(value: unknown) { + if (Array.isArray(value) && value[1] === definition) exposures++; + defineProperty(this, "0", { + value, + enumerable: true, + configurable: true, + writable: true, + }); + }, + }); + const assembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + signal: new AbortController().signal, + taskContext: { projectId: "synthetic-project", model: "openai/gpt-5.4-nano" }, + instructions: "Synthetic instructions", + sourceIntegrationPolicy: { schemaVersion: 1, mode: "unrestricted" }, + localTools: { web_fetch: definition }, + allowedToolNames: [], + allowedProviderToolNames: ["web_fetch"], + remoteToolSources: [], + }); + names = assembly.localToolNames; + } finally { + if (original) defineProperty(Array.prototype, "0", original); + else delete (Array.prototype as unknown as Record)["0"]; + } + assertEquals(names, ["web_fetch"]); + assertEquals(exposures, 0); + }); + + it("materializes private definitions without invoking inherited metadata getters", () => { + const definition = { + description: "Synthetic private tool", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => ({ ok: true }), + }; + const fields = ["inputSchemaJson", "mcp"]; + const originals = fields.map((field) => + Object.getOwnPropertyDescriptor(Object.prototype, field) + ); + const defineProperty = Object.defineProperty; + let exposures = 0; + let materialized = false; + try { + for (const field of fields) { + defineProperty(Object.prototype, field, { + configurable: true, + get() { + if (this === definition) exposures++; + return undefined; + }, + set(value: unknown) { + defineProperty(this, field, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + }, + }); + } + materialized = createToolsFromHostDefinitions({ private: definition }).private !== undefined; + } finally { + for (let index = 0; index < fields.length; index++) { + const field = fields[index]!; + const original = originals[index]; + if (original) defineProperty(Object.prototype, field, original); + else delete (Object.prototype as Record)[field]; + } + } + assertEquals(materialized, true); + assertEquals(exposures, 0); + }); + it("keeps trusted tools private when weak collection methods and object enumeration are replaced", () => { const source = markTrustedHostToolProvenance({ execute: () => ({ ok: true }) }); const target = { execute: source.execute }; From c5fb8df7506277c69421d365068594e44c6d855e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 17:01:05 +0200 Subject: [PATCH 084/194] fix(agent): isolate optional private facades from inherited getters --- src/agent/hosted/executor-runtime-prepare.ts | 2 ++ .../executor-runtime-private-facades.test.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 6fe1ae09e6..e5c61ca0f3 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -79,6 +79,7 @@ const mapGet = Map.prototype.get; const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectEntries = Object.entries; const arrayIncludes = Array.prototype.includes; const abortController = AbortController.prototype.abort; @@ -238,6 +239,7 @@ export function createExecutorRuntimePreparation(input: Options) { hostTools: new Map(input.facades.hostTools), remoteToolSources: new Map(input.facades.remoteToolSources), }; + objectSetPrototypeOf(facades, null); const lifetime = new AbortController(); let preparation: Promise | undefined; let preparedOperations: ReadonlyMap | undefined; diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 78bf6572ea..8c950d2756 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -81,6 +81,11 @@ describe("private executor facades", () => { "constructor", )!; const originalArrayZero = Object.getOwnPropertyDescriptor(Array.prototype, "0"); + const originalProjectSteering = Object.getOwnPropertyDescriptor( + Object.prototype, + "projectSteering", + ); + const originalMapGet = Map.prototype.get; let hiddenExecutions = 0; let remoteExecutions = 0; let exposedFacadeValues = 0; @@ -132,6 +137,16 @@ describe("private executor facades", () => { signal: new AbortController().signal, backend: { load: () => { + Object.defineProperty(Object.prototype, "projectSteering", { + configurable: true, + get() { + if (Object.hasOwn(this, "hostTools")) { + const tools = Reflect.apply(originalMapGet, this.hostTools, ["local"]); + if (tools?.hidden === hidden) hidden.execute(); + } + return undefined; + }, + }); Object.defineProperty(Object.prototype, "ownerAgentId", { configurable: true, get() { @@ -265,6 +280,11 @@ describe("private executor facades", () => { Object.defineProperty(Array.prototype, "constructor", originalArrayConstructor); if (originalArrayZero) Object.defineProperty(Array.prototype, "0", originalArrayZero); else delete (Array.prototype as unknown as Record)["0"]; + if (originalProjectSteering) { + Object.defineProperty(Object.prototype, "projectSteering", originalProjectSteering); + } else { + delete (Object.prototype as Record).projectSteering; + } await owner.close(); } }); From fe9db1748f17e3b791eb4b10ad10cad99cf7a60c Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 17:10:25 +0200 Subject: [PATCH 085/194] fix(agent): retain failed and cancelled broker work until retirement --- .../managed-broker-project-state.test.ts | 36 ++++++++- .../hosted/managed-broker-project-state.ts | 6 +- .../service/managed-broker-handler.test.ts | 73 ++++++++++++++++++- src/agent/service/managed-broker-handler.ts | 68 +++++++++++++++-- .../agent/managed-broker-imports.test.ts | 11 ++- 5 files changed, 179 insertions(+), 15 deletions(-) diff --git a/src/agent/hosted/managed-broker-project-state.test.ts b/src/agent/hosted/managed-broker-project-state.test.ts index af5d5f023d..817b6d9082 100644 --- a/src/agent/hosted/managed-broker-project-state.test.ts +++ b/src/agent/hosted/managed-broker-project-state.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createManagedBrokerProjectState } from "./managed-broker-project-state.ts"; @@ -12,6 +12,40 @@ const definition = { }; describe("managed broker project state", () => { + it("joins the original catalog lookup before propagating an instruction failure", async () => { + const failure = new Error("synthetic instruction failure"); + const catalog = Promise.withResolvers(); + let listingCalls = 0; + const state = createManagedBrokerProjectState({ + apiUrl: "https://api.example.test", + authToken: "broker-token", + agentId: "coder", + projectId: "project-1", + fetch: (value) => { + const url = new URL(value); + if (url.pathname.endsWith("/AGENTS.md")) return Promise.reject(failure); + listingCalls++; + return listingCalls === 1 + ? catalog.promise + : Promise.resolve(Response.json({ data: [], page_info: { next: null } })); + }, + }); + let rejected = false; + const pending = state.prepareProjectSteering({ + definition, + projectId: "project-1", + signal: new AbortController().signal, + }).catch((error) => { + rejected = true; + throw error; + }); + void pending.catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(rejected, false); + catalog.resolve(Response.json({ data: [], page_info: { next: null } })); + assertStrictEquals(await assertRejects(() => pending), failure); + }); + it("uses fixed project authorization and performs a complete refresh", async () => { const calls: Array<{ url: URL; authorization: string | null }> = []; let instructionRead = 0; diff --git a/src/agent/hosted/managed-broker-project-state.ts b/src/agent/hosted/managed-broker-project-state.ts index 157d056c61..746199344f 100644 --- a/src/agent/hosted/managed-broker-project-state.ts +++ b/src/agent/hosted/managed-broker-project-state.ts @@ -70,7 +70,7 @@ export function createManagedBrokerProjectState( signal.throwIfAborted(); if (projectId === null) return { instructions: "", skills: [] as RuntimeSkillDefinition[] }; const lookup = { projectId, branchId, authToken }; - const [instructions, skills] = await Promise.all([ + const [instructionsResult, skillsResult] = await Promise.allSettled([ getRuntimeProjectInstructions({ ...lookup, getProjectFile: fileReader(signal) }), getRuntimeProjectSkillCatalog({ ...lookup, @@ -80,8 +80,10 @@ export function createManagedBrokerProjectState( skillDocumentParserProvider: options.skillDocumentParserProvider, }), ]); + if (instructionsResult.status === "rejected") throw instructionsResult.reason; + if (skillsResult.status === "rejected") throw skillsResult.reason; signal.throwIfAborted(); - return { instructions, skills }; + return { instructions: instructionsResult.value, skills: skillsResult.value }; }; const select = (agent: RuntimeAgentMarkdownDefinition, skills: RuntimeSkillDefinition[]) => { if (agent.skills === false) return createNoneSkillSelectorSnapshot(); diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index 7606e5fb41..b2cdd8dfb7 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -52,7 +52,10 @@ async function request(signal?: AbortSignal) { }; } -function runtimeFixture(streamFailure = false) { +function runtimeFixture( + streamFailure = false, + terminalChunk?: "error" | "finish-error", +) { const release = Promise.withResolvers(); const settled = Promise.withResolvers(); const acceptKinds: string[] = []; @@ -82,6 +85,14 @@ function runtimeFixture(streamFailure = false) { toUIMessageStream: async function* () { await release.promise; if (streamFailure) throw new Error("synthetic stream failure"); + if (terminalChunk === "error") { + yield { type: "error", errorText: "ordinary stream error" } as const; + return; + } + if (terminalChunk === "finish-error") { + yield { type: "finish", finishReason: "error" } as const; + return; + } yield { type: "start", messageId: "assistant-message" } as const; }, }; @@ -109,18 +120,20 @@ async function handler( waitForAuthorization?: boolean; throwingObserver?: boolean; streamFailure?: boolean; + terminalChunk?: "error" | "finish-error"; missingOutput?: boolean; waitForOutput?: boolean; startError?: Error; } = {}, ) { const first = await request(options.signal); - const fixture = runtimeFixture(options.streamFailure); + const fixture = runtimeFixture(options.streamFailure, options.terminalChunk); let prepareCalls = 0; let brokerStarts = 0; let cleanupCalls = 0; const outputChunks: string[] = []; const outputFinishes: boolean[] = []; + const outputFinishErrors: unknown[] = []; const outputRelease = Promise.withResolvers(); let prepareSignal: AbortSignal | undefined; const prepareEntered = Promise.withResolvers(); @@ -166,8 +179,9 @@ async function handler( async write(chunk: { type: string }) { outputChunks.push(chunk.type); }, - async finish(outcome: { completed: boolean }) { + async finish(outcome: { completed: boolean; error?: unknown }) { outputFinishes.push(outcome.completed); + outputFinishErrors.push(outcome.error); if (options.waitForOutput) await outputRelease.promise; }, }, @@ -193,6 +207,7 @@ async function handler( releaseAuthorization: authorizationRelease.resolve, outputChunks, outputFinishes, + outputFinishErrors, releaseOutput: outputRelease.resolve, get prepareCalls() { return prepareCalls; @@ -266,6 +281,37 @@ describe("managed broker handler", () => { assertEquals(f.fixture.closeReasons, ["completed"]); }); + it("does not acknowledge a duplicate while the original run is still pending admission", async () => { + const f = await handler("detached", { waitForPrepare: true, failStart: true }); + const duplicateRequest = f.first.request.clone(); + const original = f.managed.handle(f.first.request); + await f.prepareEntered; + + const duplicate = await f.managed.handle(duplicateRequest); + assertEquals(duplicate.status, 409); + assertEquals(await duplicate.json(), { errorCode: "BROKER_RUN_PENDING" }); + assertEquals(f.prepareCalls, 1); + + f.releasePrepare(); + assertEquals((await original).status, 500); + assertEquals(f.managed.active, 0); + await f.managed.close(); + }); + + it("marks ordinary error chunks and error finish reasons as failed durable output", async () => { + for (const terminalChunk of ["error", "finish-error"] as const) { + const f = await handler("detached", { terminalChunk }); + assertEquals((await f.managed.handle(f.first.request)).status, 202); + f.fixture.release(); + await f.managed.close(); + + assertEquals(f.outputFinishes, [false], terminalChunk); + assertEquals(f.outputFinishErrors[0] instanceof Error, true, terminalChunk); + assertEquals(f.outputChunks, [terminalChunk === "error" ? "error" : "finish"]); + assertEquals(f.fixture.closeReasons, ["canceled"], terminalChunk); + } + }); + it("preserves request-owned SSE and releases only after response completion", async () => { const f = await handler("sse"); const duplicateRequest = f.first.request.clone(); @@ -340,6 +386,27 @@ describe("managed broker handler", () => { assertEquals(f.fixture.closeReasons, ["canceled"]); }); + it("cancels and retires request-owned SSE when the response body is canceled", async () => { + const f = await handler("sse"); + const response = await f.managed.handle(f.first.request); + let closing: Promise | undefined; + try { + await response.body?.cancel("client stopped reading"); + closing = f.managed.close(); + const timeout = Promise.withResolvers(); + const timeoutId = setTimeout(() => timeout.resolve(false), 25); + const retired = await Promise.race([closing.then(() => true), timeout.promise]); + clearTimeout(timeoutId); + assertEquals(retired, true); + assertEquals(f.fixture.closeReasons, ["canceled"]); + assertEquals(f.managed.active, 0); + assertEquals(f.cleanupCalls, 1); + } finally { + f.fixture.release(); + await closing; + } + }); + it("shields throwing detached execution observers", async () => { const f = await handler("detached", { streamFailure: true, throwingObserver: true }); const response = await f.managed.handle(f.first.request); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index a92b539fdc..f1235995e6 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -56,7 +56,7 @@ export function createManagedBrokerHandler(options: { }>; onExecutionError?: (error: unknown, runId: string) => void; }) { - const active = new Map>(); + const active = new Map }>(); const lifetime = new AbortController(); let closed = false; @@ -82,18 +82,23 @@ export function createManagedBrokerHandler(options: { }); assertAvailable(closed, signal); const runKey = managedRunKey(ingress); - if (active.has(runKey)) { + const existing = active.get(runKey); + if (existing) { + if (!existing.accepted) { + return Response.json({ errorCode: "BROKER_RUN_PENDING" }, { status: 409 }); + } return options.responseMode === "detached" ? Response.json({ accepted: true, duplicate: true }, { status: 202 }) : Response.json({ errorCode: "BROKER_RUN_ALREADY_ACTIVE" }, { status: 409 }); } const reservation = Promise.withResolvers(); - active.set(runKey, reservation.promise); + const activeRun = { accepted: false, settled: reservation.promise }; + active.set(runKey, activeRun); let preparedCleanup: (() => Promise) | undefined; let admissionSettled: Promise | undefined; let retirement: Promise | undefined; const release = () => { - if (active.get(runKey) === reservation.promise) active.delete(runKey); + if (active.get(runKey) === activeRun) active.delete(runKey); reservation.resolve(); }; const retire = (settled: Promise = Promise.resolve()) => { @@ -129,6 +134,7 @@ export function createManagedBrokerHandler(options: { ? { kind: "execution", signal: prepared.executionSignal } : { kind: "request" }, ); + activeRun.accepted = true; } catch (error) { await runtime.close("canceled").catch(() => {}); void retire(runtime.settled).catch(() => {}); @@ -202,7 +208,7 @@ export function createManagedBrokerHandler(options: { async function close(): Promise { closed = true; lifetime.abort(); - await Promise.allSettled([...active.values()]); + await Promise.allSettled([...active.values()].map((run) => run.settled)); } return { @@ -263,7 +269,7 @@ async function createSseResponse(input: { completion.resolve(); } })(); - return createAgUiChatUiTrackedResponse({ + const response = createAgUiChatUiTrackedResponse({ agUiInput: input.agUiInput, defaults: { runId: input.runId, threadId: input.threadId }, agentId: input.agentId, @@ -279,6 +285,42 @@ async function createSseResponse(input: { }, }, }); + return withResponseBodyCancellation(response, () => finish("canceled")); +} + +function withResponseBodyCancellation( + response: Response, + cancel: () => Promise, +): Response { + if (!response.body) return response; + const reader = response.body.getReader(); + const body = new ReadableStream({ + async pull(controller) { + try { + const result = await reader.read(); + if (result.done) { + controller.close(); + reader.releaseLock(); + return; + } + controller.enqueue(result.value); + } catch (error) { + await cancel().catch(() => {}); + controller.error(error); + } + }, + async cancel(reason) { + await Promise.allSettled([ + reader.cancel(reason), + cancel(), + ]); + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); } async function runDetached( @@ -294,10 +336,20 @@ async function runDetached( let failure: unknown; try { const result = await runtime.agent.stream({ messages, abortSignal: signal }); - for await (const chunk of result.toUIMessageStream()) await output.write(chunk); + for await (const chunk of result.toUIMessageStream()) { + await output.write(chunk); + if (chunk.type === "error" && failure === undefined) { + failure = new Error(chunk.errorText || "Agent stream failed"); + } else if ( + chunk.type === "finish" && chunk.finishReason === "error" && failure === undefined + ) { + failure = new Error("Agent stream finished with an error"); + } + } + if (failure !== undefined) throw failure; streamCompleted = true; } catch (error) { - failure = error; + failure ??= error; throw error; } finally { await output.finish({ diff --git a/tests/integration/agent/managed-broker-imports.test.ts b/tests/integration/agent/managed-broker-imports.test.ts index 02b04652ad..887ac767d0 100644 --- a/tests/integration/agent/managed-broker-imports.test.ts +++ b/tests/integration/agent/managed-broker-imports.test.ts @@ -3,7 +3,11 @@ import { fileURLToPath } from "node:url"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; -type Module = { specifier: string; dependencies?: { code?: { specifier: string } }[] }; +type Module = { + specifier: string; + error?: unknown; + dependencies?: { code?: { specifier: string } }[]; +}; type Graph = { roots: string[]; modules: Module[] }; const root = fileURLToPath(new URL("../../../", import.meta.url)); const forbidden = [ @@ -59,6 +63,11 @@ for ( if (dependency.code) pending.push(dependency.code.specifier); } } + assertEquals( + [...visited].filter((specifier) => modules.get(specifier)?.error !== undefined), + [], + "Runtime imports must resolve before the boundary graph can pass", + ); assertEquals( [...visited].filter((specifier) => forbidden.some((path) => specifier.endsWith(path))), [], From 856402f79f2313a420e7cb69087a9d00dc82e239 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 17:26:59 +0200 Subject: [PATCH 086/194] fix(agent): retain native promise work and class host definitions --- src/agent/hosted/executor-discovery.ts | 25 +++---- src/agent/hosted/executor-runtime-prepare.ts | 22 +++--- src/security/own-data-property.ts | 19 ++++++ src/security/private-promise.test.ts | 37 ++++++++++ src/security/private-promise.ts | 71 ++++++++++++++++---- src/tool/host-tools.test.ts | 17 +++++ src/tool/host-tools.ts | 41 ++++++++--- 7 files changed, 190 insertions(+), 42 deletions(-) create mode 100644 src/security/own-data-property.ts create mode 100644 src/security/private-promise.test.ts diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index aabb8da0d9..91ee712bc6 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -3,6 +3,7 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { chainPrivatePromise as chain, createPrivateDeferred, + observePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { @@ -119,7 +120,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut for (const task of runtimeTasks) await task; } cleanupStarted = true; - if (loadStarted) await backend?.cleanup(runtime); + if (loadStarted && backend) await observePrivatePromise(backend.cleanup(runtime)); } catch { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLEANUP_FAILED"); } finally { @@ -151,10 +152,10 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } loadStarted = true; try { - runtime = await backend.load(lifetime.signal); + runtime = await observePrivatePromise(backend.load(lifetime.signal)); assertActive(); // Validate the whole catalog before publishing local or wire access. - const module = await helpers(); + const module = await observePrivatePromise(helpers()); parseDiscoveryData( getExecutorDiscoveryCandidatesSchema(), module.getProjectAgentRuntimeAgentIdCandidates(runtime), @@ -174,14 +175,14 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut if (definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_BUSY"); } - const module = await helpers(); + const module = await observePrivatePromise(helpers()); const found = discovery.agents.get(agentId); let definition: RuntimeAgentMarkdownDefinition; if (found && module.doesProjectAgentRuntimeAgentMatchSource(found, agentSource)) { - const projected = await module.runWithProjectAgentRuntime( + const projected = await observePrivatePromise(module.runWithProjectAgentRuntime( discovery, () => module.createRuntimeAgentDefinitionFromAgent(found), - ); + )); definition = { ...projected, id: agentId }; } else { if (agentSource === "code") throw new ExecutorDiscoveryError("AGENT_NOT_FOUND"); @@ -243,7 +244,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut throw new ExecutorDiscoveryError("ABORTED"); } assertActive(); - const value = await operation(); + const value = await observePrivatePromise(operation()); assertActive(); if (context.signal.aborted || Date.now() >= context.deadline) { onCancel(); @@ -280,13 +281,13 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut handle(value, context) { return execute(context, async () => { parseDiscoveryData(getExecutorDiscoveryRequestSchema(), value); - const discovery = await discover(); - const module = await helpers(); + const discovery = await observePrivatePromise(discover()); + const module = await observePrivatePromise(helpers()); const candidates = module.getProjectAgentRuntimeAgentIdCandidates(discovery); const defaultAgentId = defaultId ?? module.resolveSingleProjectAgentRuntimeAgentId({ candidates, source: agentSource }); if (!defaultAgentId) throw new ExecutorDiscoveryError("CONFIG_INVALID"); - const definition = await describeAgent(discovery, defaultAgentId); + const definition = await observePrivatePromise(describeAgent(discovery, defaultAgentId)); return discoverySuccess( parseDiscoveryData(getExecutorDiscoveryDescriptionSchema(), { source, @@ -304,8 +305,8 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut handle(value, context) { return execute(context, async () => { const request = parseDiscoveryData(getExecutorAgentDescribeRequestSchema(), value); - const discovery = await discover(); - const definition = await describeAgent(discovery, request.agentId); + const discovery = await observePrivatePromise(discover()); + const definition = await observePrivatePromise(describeAgent(discovery, request.agentId)); return discoverySuccess( parseDiscoveryData(getExecutorAgentDescriptionSchema(), { source, definition }, true), ); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index e5c61ca0f3..f9dcfa49ad 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -2,6 +2,7 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { chainPrivatePromise as chain, createPrivateDeferred, + observePrivatePromise, resolvePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; @@ -261,8 +262,8 @@ export function createExecutorRuntimePreparation(input: Options) { // 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, () => {}, () => {}); - await producerCompletion; - if (resourcesStarted) await facades.cleanup(); + if (producerCompletion) await observePrivatePromise(producerCompletion); + if (resourcesStarted) await observePrivatePromise(facades.cleanup()); }); return cleanup; }; @@ -277,7 +278,7 @@ export function createExecutorRuntimePreparation(input: Options) { failed = true; } try { - await input.discovery.close(); + await observePrivatePromise(input.discovery.close()); } catch { failed = true; } @@ -358,7 +359,10 @@ export function createExecutorRuntimePreparation(input: Options) { const operation = privateMapGet(input.discovery.operations, "agent.describe"); if (operation?.mode !== "unary") refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); const described = getExecutorAgentDescribeResultSchema().parse( - await operation.handle({ agentId: request.agentId }, context), + await chain( + resolvePrivatePromise(), + () => operation.handle({ agentId: request.agentId }, context), + ), ); if ( !described.ok || described.value.definition.id !== grant.agentId || @@ -445,12 +449,12 @@ export function createExecutorRuntimePreparation(input: Options) { resolveModelRuntime(modelId); const execution = grant.execution; const steering = facades.projectSteering - ? await facades.projectSteering.prepare({ + ? await observePrivatePromise(facades.projectSteering.prepare({ definition, projectId: execution.projectId, branchId: execution.branchId, signal: context.signal, - }) + })) : undefined; assertActive(); if (steering && steering.agent.id !== definition.id) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); @@ -585,7 +589,7 @@ export function createExecutorRuntimePreparation(input: Options) { if (name !== undefined) facadeAllowedToolSet.add(name); } const facadeAllowedToolNames = [...facadeAllowedToolSet]; - const toolAssembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + const toolAssembly = await observePrivatePromise(prepareFacadedHostedChatRuntimeToolAssembly({ signal: context.signal, taskContext, instructions: options.instructions, @@ -612,7 +616,7 @@ export function createExecutorRuntimePreparation(input: Options) { } }, loadLatestConversationUserText: facades.latestConversationUserText, - }); + })); assertActive(); for (const name of toolAssembly.normalizedAllowedToolNames ?? []) { if (!includes(toolAssembly.authorizedToolNames, name)) { @@ -712,7 +716,7 @@ export function createExecutorRuntimePreparation(input: Options) { }); if (context.signal.aborted) cancel(); try { - return await preparation; + return await observePrivatePromise(preparation); } finally { apply(removeEventListener, context.signal, ["abort", cancel]); } diff --git a/src/security/own-data-property.ts b/src/security/own-data-property.ts new file mode 100644 index 0000000000..c000bf6eb2 --- /dev/null +++ b/src/security/own-data-property.ts @@ -0,0 +1,19 @@ +const defineProperty = Object.defineProperty; +const hasOwn = Object.hasOwn; + +/** Define own data without inherited descriptor accessors or target setters. */ +export function defineOwnDataProperty( + target: T, + key: PropertyKey, + value: unknown, + attributes: Pick = {}, +): T { + const descriptor = { + __proto__: null, + value, + enumerable: hasOwn(attributes, "enumerable") && attributes.enumerable === true, + configurable: hasOwn(attributes, "configurable") && attributes.configurable === true, + writable: hasOwn(attributes, "writable") && attributes.writable === true, + }; + return defineProperty(target, key, descriptor); +} diff --git a/src/security/private-promise.test.ts b/src/security/private-promise.test.ts new file mode 100644 index 0000000000..621b0d9a7f --- /dev/null +++ b/src/security/private-promise.test.ts @@ -0,0 +1,37 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { chainPrivatePromise, resolvePrivatePromise } from "./private-promise.ts"; + +describe("owned promise chains", () => { + for (const phase of ["input", "callback"] as const) { + it(`joins the original ${phase} promise despite its own constructor and then hooks`, async () => { + const work = Promise.withResolvers(); + let hooks = 0; + Object.defineProperties(work.promise, { + constructor: { value: function ForeignConstructor() {}, configurable: true }, + then: { + value: (fulfilled: (value: number) => void) => { + hooks++; + fulfilled(-1); + }, + configurable: true, + }, + }); + const result = phase === "input" + ? chainPrivatePromise(work.promise, (value) => value + 1) + : chainPrivatePromise(resolvePrivatePromise(), () => work.promise); + let settled = false; + void result.then(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(settled, false); + assertEquals(hooks, 0); + } finally { + work.resolve(41); + } + assertEquals(await result, phase === "input" ? 42 : 41); + }); + } +}); diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index ce3392033e..23a5b76eb8 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -1,30 +1,75 @@ -const PromiseConstructor = Promise; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const NativePromise = Promise; const apply = Reflect.apply; +const promiseThen = Promise.prototype.then; const promiseResolve = Promise.resolve; const promiseWithResolvers = Promise.withResolvers; +const nativeHasInstance = Function.prototype[Symbol.hasInstance]; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const freeze = Object.freeze; +const species: typeof Symbol.species = Symbol.species; + +function isNativePromise(value: unknown): value is Promise { + return apply(nativeHasInstance, NativePromise, [value]) as boolean; +} + +function protectResult(value: T): T { + if (isNativePromise(value)) protectPromise(value); + return value; +} + +class PrivatePromise extends NativePromise { + static override get [species](): typeof PrivatePromise { + return PrivatePromise; + } + + override then( + fulfilled?: ((value: T) => F | PromiseLike) | null, + rejected?: ((reason: unknown) => R | PromiseLike) | null, + ): Promise { + return apply(promiseThen, this, [ + typeof fulfilled === "function" ? (value: T) => protectResult(fulfilled(value)) : fulfilled, + typeof rejected === "function" + ? (reason: unknown) => protectResult(rejected(reason)) + : rejected, + ]) as Promise; + } +} +freeze(PrivatePromise.prototype); +freeze(PrivatePromise); +const privateThen = PrivatePromise.prototype.then; + +function protectPromise(promise: Promise): Promise { + if (getOwnPropertyDescriptor(promise, "constructor")?.value !== PrivatePromise) { + defineOwnDataProperty(promise, "constructor", PrivatePromise); + defineOwnDataProperty(promise, "then", privateThen); + } + return promise; +} -/** Await owned native promises without dispatching through replaced promise methods. */ -export async function chainPrivatePromise( +/** Observe owned native work through fixed constructor, species, and chaining methods. */ +export function chainPrivatePromise( promise: Promise, fulfilled: (value: T) => U | PromiseLike, rejected?: (reason: unknown) => U | PromiseLike, ): Promise { - let value: T; - try { - value = await promise; - } catch (error) { - if (rejected) return await rejected(error); - throw error; - } - return await fulfilled(value); + return apply(privateThen, protectPromise(promise), [fulfilled, rejected]) as Promise; +} + +/** Join a native operation before exposing its result to a lifecycle await. */ +export function observePrivatePromise(promise: Promise): Promise { + return chainPrivatePromise(promise, (value) => value); } /** Create the initial settled promise for an owned lifecycle chain. */ export function resolvePrivatePromise(): Promise { - return apply(promiseResolve, PromiseConstructor, []) as Promise; + return protectPromise(apply(promiseResolve, NativePromise, []) as Promise); } /** Create an owned completion latch without consulting a replaced constructor helper. */ export function createPrivateDeferred(): PromiseWithResolvers { - return apply(promiseWithResolvers, PromiseConstructor, []) as PromiseWithResolvers; + const deferred = apply(promiseWithResolvers, NativePromise, []) as PromiseWithResolvers; + protectPromise(deferred.promise); + return deferred; } diff --git a/src/tool/host-tools.test.ts b/src/tool/host-tools.test.ts index 366ffe8cac..d090394a66 100644 --- a/src/tool/host-tools.test.ts +++ b/src/tool/host-tools.test.ts @@ -15,6 +15,23 @@ import type { RemoteToolSource, ToolExecutionContext, ToolSet } from "./types.ts const emptyJsonSchema = { type: "object" as const, properties: {} }; describe("tool/host-tools", () => { + it("materializes class-defined execution methods with their original receiver", async () => { + class Definition { + description = "Synthetic class tool"; + inputSchema = defineSchema((v) => v.object({}))(); + executions = 0; + execute() { + this.executions++; + return { executions: this.executions }; + } + } + const definition = new Definition(); + const tools = createToolsFromHostDefinitions({ synthetic: definition }); + assertEquals(Object.keys(tools), ["synthetic"]); + assertEquals(await tools.synthetic?.execute({}), { executions: 1 }); + assertEquals(definition.executions, 1); + }); + it("ignores inherited optional metadata and preserves the original execution receiver", async () => { let metadataReads = 0; const definition: HostToolSet[string] = Object.create({ diff --git a/src/tool/host-tools.ts b/src/tool/host-tools.ts index 8692ba1971..ff15497128 100644 --- a/src/tool/host-tools.ts +++ b/src/tool/host-tools.ts @@ -4,13 +4,16 @@ import type { JsonSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import type { Tool, ToolConfig, ToolExecutionContext, ToolSet } from "./types.ts"; import { getRemoteToolProvenance, markRemoteToolProvenance } from "./remote-tool-provenance.ts"; import { inheritTrustedHostToolProvenance } from "./host-tool-provenance.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; const apply = Reflect.apply; const arrayIsArray = Array.isArray; const objectEntries = Object.entries; -const objectDefineProperty = Object.defineProperty; const objectCreate = Object.create; const objectGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectGetPrototypeOf = Object.getPrototypeOf; +const objectPrototype = Object.prototype; const objectHasOwn = Object.hasOwn; const objectKeys = Object.keys; @@ -96,7 +99,29 @@ function snapshotHostToolDefinition(value: unknown): Record | u const key = keys[index]!; const descriptor = descriptors[key]; if (descriptor && objectHasOwn(descriptor, "value")) { - objectDefineProperty(snapshot, key, { value: descriptor.value, enumerable: true }); + defineOwnDataProperty(snapshot, key, descriptor.value, { enumerable: true }); + } + } + const prototypeFields: (keyof HostToolDefinition)[] = [ + "description", + "execute", + "inputSchema", + "inputSchemaJson", + "mcp", + ]; + for (let index = 0; index < prototypeFields.length; index++) { + const key = prototypeFields[index]!; + if (objectHasOwn(snapshot, key)) continue; + let current: Record | null = value; + for (let depth = 0; current !== null && current !== objectPrototype && depth < 128; depth++) { + const descriptor = objectGetOwnPropertyDescriptor(current, key); + if (descriptor !== undefined) { + if (objectHasOwn(descriptor, "value")) { + defineOwnDataProperty(snapshot, key, descriptor.value, { enumerable: true }); + } + break; + } + current = objectGetPrototypeOf(current); } } return snapshot; @@ -174,12 +199,12 @@ export function createToolsFromHostDefinitions( const toolWithRemoteProvenance = canonicalRemoteToolName ? markRemoteToolProvenance(materializedTool, canonicalRemoteToolName) : materializedTool; - objectDefineProperty(tools, toolName, { - value: inheritTrustedHostToolProvenance(originalDefinition, toolWithRemoteProvenance), - enumerable: true, - configurable: true, - writable: true, - }); + defineOwnDataProperty( + tools, + toolName, + inheritTrustedHostToolProvenance(originalDefinition, toolWithRemoteProvenance), + { enumerable: true, configurable: true, writable: true }, + ); } } catch (error) { agentLogger.warn("Skipping host tool: schema conversion failed", { From d333528027485a8eeb031cc79495f1438fc770ff Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 17:38:49 +0200 Subject: [PATCH 087/194] fix(agent): isolate private assembly and constructor records --- .../hosted/chat-runtime-tool-assembly.ts | 5 +- src/agent/hosted/executor-runtime-prepare.ts | 62 +++++++++++-------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index f538cc3ce4..e06e4f21c6 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -54,6 +54,7 @@ const arraySort = Array.prototype.sort; const objectDefineProperty = Object.defineProperty; const objectEntries = Object.entries; const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectHasOwn = Object.hasOwn; const objectKeys = Object.keys; @@ -725,7 +726,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ? undefined : preparedInstructions; - return { + const result: FacadedHostedChatRuntimeToolAssemblyResult = { normalizedAllowedToolNames, authorizedToolNames, sourceIntegrationPolicy: input.sourceIntegrationPolicy, @@ -741,6 +742,8 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< systemInstructions, ...(systemMessages === undefined ? {} : { systemMessages }), }; + if ("remoteToolSources" in input) objectSetPrototypeOf(result, null); + return result; } /** Prepare hosted chat runtime tool assembly. */ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index f9dcfa49ad..889a4cdddf 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -550,6 +550,7 @@ export function createExecutorRuntimePreparation(input: Options) { } : {}), }; + objectSetPrototypeOf(options, null); const remoteToolSources: RemoteToolSource[] = []; for (let index = 0; index < grant.remoteToolSourceIds.length; index++) { const id = grant.remoteToolSourceIds[index]; @@ -589,7 +590,7 @@ export function createExecutorRuntimePreparation(input: Options) { if (name !== undefined) facadeAllowedToolSet.add(name); } const facadeAllowedToolNames = [...facadeAllowedToolSet]; - const toolAssembly = await observePrivatePromise(prepareFacadedHostedChatRuntimeToolAssembly({ + const assemblyInput: Parameters[0] = { signal: context.signal, taskContext, instructions: options.instructions, @@ -616,36 +617,45 @@ export function createExecutorRuntimePreparation(input: Options) { } }, 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) + : 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({ - options, - taskContext, - toolAssembly: { - ...toolAssembly, - runtimeTools: scopeHostedRuntimeToolResults(toolAssembly.runtimeTools), - }, - modelId, - sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - refreshSystem: facades.projectSteering - ? () => facades.projectSteering!.refresh(streamSignal) - : undefined, - }, { - resolveModelRuntime, - preserveToolCatalog: true, - onStreamCompletion: (completion) => { - producerCompletion = completion; - input.discovery.retainRuntimeTask(completion); - }, - }), + () => createPreparedHostedRuntimeAgent(runtimeInput, runtimeOptions), ); assertActive(); const preparedRuntimeHandle = crypto.randomUUID(); @@ -655,7 +665,7 @@ export function createExecutorRuntimePreparation(input: Options) { streamSignal = streamInput.abortSignal; startup = chain(resolvePrivatePromise(), () => { assertActive(); - return createHostedChatRuntimeDataStream({ + const streamOptions: Parameters[0] = { runtimeAgent, sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, agentId: definition.id, @@ -665,7 +675,9 @@ export function createExecutorRuntimePreparation(input: Options) { ? { runId: execution.runId, conversationId: execution.conversationId } : {}), maxOutputTokens: options.maxOutputTokens, - }, streamInput); + }; + objectSetPrototypeOf(streamOptions, null); + return createHostedChatRuntimeDataStream(streamOptions, streamInput); }); input.discovery.retainRuntimeTask(startup); return startup; From 8d4a7af35d846160e5ae3a035af569d57effcd21 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 17:44:25 +0200 Subject: [PATCH 088/194] fix(agent): finalize durable failures and wire broker server lifecycle --- docs/api-reference/veryfront/agent.md | 9 +- docs/guides/agent-service-runtime.md | 7 + scripts/test/coverage-node-executor.mjs | 9 +- .../hosted/managed-broker-persistence.ts | 57 +++++-- src/agent/service/broker-ingress.ts | 6 +- .../service/managed-broker-handler.test.ts | 16 +- src/agent/service/managed-broker-handler.ts | 4 +- src/agent/service/managed-broker.ts | 5 + .../service/managed-hosted-ingress.test.ts | 4 + src/agent/service/managed-hosted-ingress.ts | 18 +- src/agent/service/managed-node-broker.ts | 144 ++++++++++++++++ .../agent}/executor-runtime-contracts.test.ts | 2 +- .../agent/managed-broker-imports.test.ts | 1 + .../agent}/managed-broker-persistence.test.ts | 33 +++- .../managed-broker-project-state.test.ts | 2 +- .../agent/managed-node-broker.test.ts | 160 ++++++++++++++++++ .../ci/node-executor-coverage.test.ts | 66 +++++++- 17 files changed, 509 insertions(+), 34 deletions(-) create mode 100644 src/agent/service/managed-node-broker.ts rename {src/agent/hosted => tests/integration/agent}/executor-runtime-contracts.test.ts (95%) rename {src/agent/hosted => tests/integration/agent}/managed-broker-persistence.test.ts (82%) rename {src/agent/hosted => tests/integration/agent}/managed-broker-project-state.test.ts (98%) create mode 100644 tests/integration/agent/managed-node-broker.test.ts diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 9b44f18049..6f94aec1d7 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -2005,12 +2005,13 @@ import { | `parseBrokerRuntimeAgentIngress` | Read and verify a signed invocation once before constructing executor-safe data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | | `parseManagedAgUiAgentIngress` | Parse the trusted broker's direct request-owned AG-UI ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | | `parseManagedDurableAgentIngress` | Parse the trusted broker's direct canonical durable-run ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `startNodeManagedAgentBroker` | Bind managed routes and stop admission before joining all work during shutdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | #### Classes -| Name | Description | Source | -| -------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `BrokerIngressError` | Fixed ingress failures contain no body, signature, credential, or verifier diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| Name | Description | Source | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `BrokerIngressError` | Local HTTP-boundary errors intentionally avoid the application error registry: only a fixed code/status is exposed, never body, credential, or verifier diagnostics. These errors stay in the broker and are not executor-channel error contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | #### Types @@ -2031,3 +2032,5 @@ import { | `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | | `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | | `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedNodeBrokerHandler` | Trusted broker route handler and optional retirement hook. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | +| `ManagedNodeBrokerPool` | Broker admission and settlement lifecycle retained by the HTTP server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index fec73ed660..123879cbda 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -448,6 +448,13 @@ model and tool execution unavailable during preparation. Configure detached Detached runs require output persistence callbacks; their finalization remains part of the session's owned work until all writes settle. +`startNodeManagedAgentBroker` binds the signed stream, durable start, AG-UI, +and cancel/resume handlers to a Node server. Supply every handler, the broker +pool, and a readiness check explicitly. It preserves `/liveness` and +`/readiness`; shutdown stops admission before waiting for handlers and broker +work to retire. This server adapter does not configure product policy, +registration, credentials, or executor images. + ## Verify it worked Start the service entrypoint and call the run route directly. The default diff --git a/scripts/test/coverage-node-executor.mjs b/scripts/test/coverage-node-executor.mjs index 3e0e1339f8..933aac18a1 100644 --- a/scripts/test/coverage-node-executor.mjs +++ b/scripts/test/coverage-node-executor.mjs @@ -101,10 +101,17 @@ export async function validateNativeCoverage( first--; } first = Math.max(1, first - 1); + const functionEndOffset = anchor && lines.slice(anchor.line).findIndex((line) => line === "}"); + const functionEnd = functionEndOffset === -1 || !anchor + ? undefined + : anchor.line + functionEndOffset + 1; const mappedLine = anchor && entry.functions.get(anchor.name); if ( !anchor || mappedLine === undefined || mappedLine < first || - mappedLine > anchor.line || + // Node LCOV can repeat a named function and place the later record at + // its first executable body range. Bound that retained record to the + // final top-level function's actual closing brace, never trailing code. + functionEnd === undefined || mappedLine > functionEnd || [...entry.lines.keys()].some((line) => line < 1 || line > lines.length) ) { throw new Error( diff --git a/src/agent/hosted/managed-broker-persistence.ts b/src/agent/hosted/managed-broker-persistence.ts index 6590ef9610..957c803970 100644 --- a/src/agent/hosted/managed-broker-persistence.ts +++ b/src/agent/hosted/managed-broker-persistence.ts @@ -70,16 +70,23 @@ export function createManagedBrokerPersistence(input: { let finished = false; let cleaned = false; - const queue = (operation: () => Promise, terminal = false): Promise => { + const queue = ( + operation: (priorFailure: { failed: boolean; error: unknown }) => Promise, + terminal = false, + ): Promise => { if (cleaned) return Promise.reject(new TypeError("Managed broker persistence is closed")); if (finished && !terminal) { return Promise.reject(new TypeError("Managed broker persistence is finished")); } const current = tail.then(async () => { - if (failed) throw failure; + const priorFailure = { failed, error: failure }; + if (priorFailure.failed && !terminal) throw priorFailure.error; try { - return await operation(); + const result = await operation(priorFailure); + if (priorFailure.failed) throw priorFailure.error; + return result; } catch (error) { + if (priorFailure.failed) throw priorFailure.error; failure = error; failed = true; throw error; @@ -115,19 +122,39 @@ export function createManagedBrokerPersistence(input: { return Promise.reject(new TypeError("Completed managed output cannot carry an error")); } finished = true; - return queue(async () => { - await flush(); - if (result.completed) { - await terminal.dispatch({ status: "completed" }); - } else if (result.error !== undefined) { - await terminal.dispatch(resolveConversationHostedStreamErrorState(result.error)); - } else { - await terminal.dispatch({ - status: "cancelled", - terminalErrorCode: "ABORTED", - terminalErrorMessage: "Managed executor output was cancelled", - }); + return queue(async (priorFailure) => { + // Terminal reporting is an independent best-effort path: even a poisoned + // write tail or final drain must attempt a failed terminal update, while + // callers still receive the original persistence error. + let terminalFailure = priorFailure; + if (!terminalFailure.failed) { + try { + await flush(); + } catch (error) { + terminalFailure = { failed: true, error }; + } + } + try { + if (terminalFailure.failed) { + await terminal.dispatch( + resolveConversationHostedStreamErrorState(terminalFailure.error), + ); + } else if (result.completed) { + await terminal.dispatch({ status: "completed" }); + } else if (result.error !== undefined) { + await terminal.dispatch(resolveConversationHostedStreamErrorState(result.error)); + } else { + await terminal.dispatch({ + status: "cancelled", + terminalErrorCode: "ABORTED", + terminalErrorMessage: "Managed executor output was cancelled", + }); + } + } catch (terminalDispatchError) { + if (terminalFailure.failed) throw terminalFailure.error; + throw terminalDispatchError; } + if (terminalFailure.failed && !priorFailure.failed) throw terminalFailure.error; }, true); }, }; diff --git a/src/agent/service/broker-ingress.ts b/src/agent/service/broker-ingress.ts index d320c40173..ba563fbf43 100644 --- a/src/agent/service/broker-ingress.ts +++ b/src/agent/service/broker-ingress.ts @@ -60,7 +60,11 @@ export type BrokerIngressErrorCode = | "CONTROL_PLANE_AGENT_SOURCE_UNSUPPORTED" | "CONTROL_PLANE_AGENT_SOURCE_MISMATCH"; -/** Fixed ingress failures contain no body, signature, credential, or verifier diagnostics. */ +/** + * Local HTTP-boundary errors intentionally avoid the application error registry: + * only a fixed code/status is exposed, never body, credential, or verifier diagnostics. + * These errors stay in the broker and are not executor-channel error contracts. + */ export class BrokerIngressError extends Error { constructor(readonly status: number, readonly errorCode: BrokerIngressErrorCode) { super(errorCode); diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index b2cdd8dfb7..efd7d2a8d3 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -141,6 +141,7 @@ async function handler( const authorizationEntered = Promise.withResolvers(); const authorizationRelease = Promise.withResolvers(); const admitted = Promise.withResolvers(); + const executionController = new AbortController(); const managed = createManagedBrokerHandler({ responseMode: mode, broker: { @@ -174,7 +175,7 @@ async function handler( return { start: { session: {} } as ManagedExecutorStartInput, messages: [], - executionSignal: new AbortController().signal, + executionSignal: executionController.signal, output: options.missingOutput ? undefined : { async write(chunk: { type: string }) { outputChunks.push(chunk.type); @@ -209,6 +210,7 @@ async function handler( outputFinishes, outputFinishErrors, releaseOutput: outputRelease.resolve, + abortExecution: () => executionController.abort(), get prepareCalls() { return prepareCalls; }, @@ -312,6 +314,18 @@ describe("managed broker handler", () => { } }); + it("finalizes an aborted detached execution as cancelled instead of failed", async () => { + const f = await handler("detached"); + assertEquals((await f.managed.handle(f.first.request)).status, 202); + f.abortExecution(); + f.fixture.release(); + await f.managed.close(); + + assertEquals(f.outputFinishes, [false]); + assertEquals(f.outputFinishErrors, [undefined]); + assertEquals(f.fixture.closeReasons, ["canceled"]); + }); + it("preserves request-owned SSE and releases only after response completion", async () => { const f = await handler("sse"); const duplicateRequest = f.first.request.clone(); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index f1235995e6..b57b0a01df 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -220,6 +220,7 @@ export function createManagedBrokerHandler(options: { }; } +/** Private lifecycle sentinel mapped locally to a fixed HTTP error without serializing diagnostics. */ class BrokerHandlerUnavailableError extends Error {} function assertAvailable(closed: boolean, signal: AbortSignal): void { @@ -346,6 +347,7 @@ async function runDetached( failure = new Error("Agent stream finished with an error"); } } + signal.throwIfAborted(); if (failure !== undefined) throw failure; streamCompleted = true; } catch (error) { @@ -354,7 +356,7 @@ async function runDetached( } finally { await output.finish({ completed: streamCompleted, - ...(failure === undefined ? {} : { error: failure }), + ...(failure === undefined || signal.aborted ? {} : { error: failure }), }); } }); diff --git a/src/agent/service/managed-broker.ts b/src/agent/service/managed-broker.ts index e9afaa3c16..a3e097237a 100644 --- a/src/agent/service/managed-broker.ts +++ b/src/agent/service/managed-broker.ts @@ -33,3 +33,8 @@ export { parseManagedAgUiAgentIngress, parseManagedDurableAgentIngress, } from "./managed-hosted-ingress.ts"; +export { + type ManagedNodeBrokerHandler, + type ManagedNodeBrokerPool, + startNodeManagedAgentBroker, +} from "./managed-node-broker.ts"; diff --git a/src/agent/service/managed-hosted-ingress.test.ts b/src/agent/service/managed-hosted-ingress.test.ts index 94720ed430..388784ada0 100644 --- a/src/agent/service/managed-hosted-ingress.test.ts +++ b/src/agent/service/managed-hosted-ingress.test.ts @@ -151,5 +151,9 @@ describe("managed agent ingress", () => { assertEquals(result, response); assertEquals(request.bodyUsed, false); + assertEquals( + request.headers.get("X-Veryfront-Run-Event-Token"), + "must-stay-broker-private", + ); }); }); diff --git a/src/agent/service/managed-hosted-ingress.ts b/src/agent/service/managed-hosted-ingress.ts index d2338be2ba..efcaa479f2 100644 --- a/src/agent/service/managed-hosted-ingress.ts +++ b/src/agent/service/managed-hosted-ingress.ts @@ -1,6 +1,9 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; -import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; +import { + createApplicationRequest, + isInfrastructureOnlyRequestHeader, +} from "#veryfront/security/http/application-request.ts"; import { buildParsedHostedAgUiRequest, createHostedAgUiValidationErrorResponse, @@ -130,6 +133,15 @@ function sanitizeForwardedProps(value: unknown): unknown { return Object.keys(sanitized).length > 0 ? sanitized : null; } +/** Bun retains source headers when Request init supplies a replacement list. */ +function removeRetainedInfrastructureHeaders(request: Request): Request { + const headers = request.headers; + for (const name of [...headers.keys()]) { + if (isInfrastructureOnlyRequestHeader(name)) headers.delete(name); + } + return request; +} + function boundedExecutorRequest(value: unknown): ManagedAgentExecutorRequest { const snapshot = snapshotBoundedJsonValue(value); if (!snapshot.success) { @@ -242,7 +254,9 @@ export async function parseManagedAgUiAgentIngress( request: Request, options: ParseManagedAgUiAgentIngressOptions, ): Promise { - const applicationRequest = createApplicationRequest(request); + const applicationRequest = removeRetainedInfrastructureHeaders( + createApplicationRequest(request), + ); const principal = await options.authenticate(applicationRequest); if (isResponseLike(principal)) return principal; diff --git a/src/agent/service/managed-node-broker.ts b/src/agent/service/managed-node-broker.ts new file mode 100644 index 0000000000..2755cb9323 --- /dev/null +++ b/src/agent/service/managed-node-broker.ts @@ -0,0 +1,144 @@ +import { + createVeryfrontServer, + type NodeVeryfrontServiceServer, + startNodeVeryfrontServer, + type VeryfrontServiceServerLogger, +} from "../../server/service-server.ts"; + +/** Trusted broker route handler and optional retirement hook. */ +export interface ManagedNodeBrokerHandler { + handle(request: Request, input: { runId?: string }): Response | Promise; + close?: () => void | Promise; +} + +/** Broker admission and settlement lifecycle retained by the HTTP server. */ +export interface ManagedNodeBrokerPool { + shutdown(): Promise; + readonly closed: Promise; + readonly settled: Promise; +} + +/** Bind managed routes and stop admission before joining all work during shutdown. */ +export async function startNodeManagedAgentBroker(options: { + port: number; + bindAddress?: string; + signals?: readonly NodeJS.Signals[]; + hardShutdownTimeoutMs?: number; + logger?: VeryfrontServiceServerLogger; + broker: ManagedNodeBrokerPool; + readiness(): boolean | Promise; + handlers: { + signedStream: ManagedNodeBrokerHandler; + durableStart: ManagedNodeBrokerHandler; + agUi: ManagedNodeBrokerHandler; + cancel: ManagedNodeBrokerHandler; + resume: ManagedNodeBrokerHandler; + }; +}): Promise { + validateOptions(options); + let shuttingDown = false; + let shutdown: Promise | undefined; + const beginShutdown = () => { + shuttingDown = true; + shutdown ??= options.broker.shutdown(); + void shutdown.catch(() => {}); + return shutdown; + }; + const handlers = Object.values(options.handlers); + const runtime = createVeryfrontServer({ + logger: options.logger, + modules: [{ + name: "managed-agent-broker", + async handle(request) { + const url = new URL(request.url); + const route = resolveRoute(request.method, url.pathname); + if (route?.kind === "liveness") return new Response("OK"); + if (route?.kind === "ready") { + const ready = !shuttingDown && await options.readiness(); + return new Response(ready ? "OK" : shuttingDown ? "Shutting down" : "Not Ready", { + status: ready ? 200 : 503, + }); + } + if (!route) return null; + if (shuttingDown) { + return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); + } + return await options.handlers[route.kind].handle(request, { + ...("runId" in route ? { runId: route.runId } : {}), + }); + }, + setShuttingDown() { + beginShutdown(); + }, + async stop() { + const results = await Promise.allSettled([ + beginShutdown(), + options.broker.closed, + options.broker.settled, + ...[...new Set(handlers)].map((handler) => handler.close?.()), + ]); + const failed = results.find((result) => result.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + }, + }], + }); + const server = await startNodeVeryfrontServer({ + runtime, + port: options.port, + bindAddress: options.bindAddress, + signals: options.signals, + hardShutdownTimeoutMs: options.hardShutdownTimeoutMs, + logger: options.logger, + }); + await server.ready; + return server; +} + +type Route = + | { kind: "signedStream" | "cancel" | "resume"; runId: string } + | { kind: "durableStart" | "agUi" | "liveness" | "ready" }; + +function resolveRoute(method: string, pathname: string): Route | undefined { + if (method === "GET" && pathname === "/liveness") return { kind: "liveness" }; + if (method === "GET" && pathname === "/readiness") return { kind: "ready" }; + if (method === "POST" && pathname === "/api/runs") return { kind: "durableStart" }; + if (method === "POST" && pathname === "/api/ag-ui") return { kind: "agUi" }; + const signed = /^\/api\/control-plane\/runs\/([^/]+)\/stream$/u.exec(pathname); + if (method === "POST" && signed) return { kind: "signedStream", runId: decodeRunId(signed[1]!) }; + const resume = /^\/api\/runs\/([^/]+)\/resume$/u.exec(pathname); + if (method === "POST" && resume) return { kind: "resume", runId: decodeRunId(resume[1]!) }; + const controlResume = /^\/api\/control-plane\/runs\/([^/]+)\/resume$/u.exec(pathname); + if (method === "POST" && controlResume) { + return { kind: "resume", runId: decodeRunId(controlResume[1]!) }; + } + const cancel = /^\/api\/runs\/([^/]+)$/u.exec(pathname); + if (method === "DELETE" && cancel) return { kind: "cancel", runId: decodeRunId(cancel[1]!) }; + const controlCancel = /^\/api\/control-plane\/runs\/([^/]+)$/u.exec(pathname); + if (method === "DELETE" && controlCancel) { + return { kind: "cancel", runId: decodeRunId(controlCancel[1]!) }; + } + return undefined; +} + +function decodeRunId(value: string): string { + const decoded = decodeURIComponent(value); + if (!decoded || decoded.includes("/")) throw new TypeError("Invalid managed broker run id"); + return decoded; +} + +function validateOptions(options: { + broker: ManagedNodeBrokerPool; + readiness: unknown; + handlers: Record; +}): void { + if ( + !options.broker || typeof options.broker.shutdown !== "function" || + !(options.broker.closed instanceof Promise) || !(options.broker.settled instanceof Promise) || + typeof options.readiness !== "function" + ) throw new TypeError("Managed broker lifecycle configuration is incomplete"); + for (const name of ["signedStream", "durableStart", "agUi", "cancel", "resume"]) { + if (typeof options.handlers?.[name]?.handle !== "function") { + throw new TypeError("Managed broker route configuration is incomplete"); + } + } +} diff --git a/src/agent/hosted/executor-runtime-contracts.test.ts b/tests/integration/agent/executor-runtime-contracts.test.ts similarity index 95% rename from src/agent/hosted/executor-runtime-contracts.test.ts rename to tests/integration/agent/executor-runtime-contracts.test.ts index 16d3402256..1de4ad3b84 100644 --- a/src/agent/hosted/executor-runtime-contracts.test.ts +++ b/tests/integration/agent/executor-runtime-contracts.test.ts @@ -2,7 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; import { tryResolve } from "#veryfront/extensions/contracts.ts"; -import { initializeExecutorRuntimeContracts } from "./executor-runtime-contracts.ts"; +import { initializeExecutorRuntimeContracts } from "#veryfront/agent/hosted/executor-runtime-contracts.ts"; it("initializes executor runtime contracts once and preserves their trusted generations", async () => { await Promise.all([ diff --git a/tests/integration/agent/managed-broker-imports.test.ts b/tests/integration/agent/managed-broker-imports.test.ts index 887ac767d0..2c73e500b1 100644 --- a/tests/integration/agent/managed-broker-imports.test.ts +++ b/tests/integration/agent/managed-broker-imports.test.ts @@ -30,6 +30,7 @@ for ( "src/agent/service/broker-ingress.ts", "src/agent/service/managed-broker-handler.ts", "src/agent/service/managed-broker.ts", + "src/agent/service/managed-node-broker.ts", ] ) { it(`keeps project runtime imports out of ${entry}`, { timeout: 30_000 }, async () => { diff --git a/src/agent/hosted/managed-broker-persistence.test.ts b/tests/integration/agent/managed-broker-persistence.test.ts similarity index 82% rename from src/agent/hosted/managed-broker-persistence.test.ts rename to tests/integration/agent/managed-broker-persistence.test.ts index 0d7a2b6665..1506b62fdd 100644 --- a/src/agent/hosted/managed-broker-persistence.test.ts +++ b/tests/integration/agent/managed-broker-persistence.test.ts @@ -2,7 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; -import { createManagedBrokerPersistence } from "./managed-broker-persistence.ts"; +import { createManagedBrokerPersistence } from "#veryfront/agent/hosted/managed-broker-persistence.ts"; const conversationId = "00000000-0000-4000-8000-000000000001"; const messageId = "00000000-0000-4000-8000-000000000002"; @@ -145,8 +145,19 @@ describe("managed broker persistence", () => { }); }); - it("propagates persistence failure through finish without reporting success", async () => { - const fetch = () => Promise.resolve(new Response("failed", { status: 500 })); + it("preserves a poisoned write error while independently finalizing the run as failed", async () => { + const calls: Record[] = []; + const fallback = successfulFetch(calls); + let failEventAppend = true; + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + if (failEventAppend && Array.isArray(body.events)) { + failEventAppend = false; + calls.push(body); + return new Response("failed", { status: 500 }); + } + return await fallback(input, init); + }; await withMockFetch(fetch, async () => { const persistence = createManagedBrokerPersistence({ apiUrl: "https://api.example.test", @@ -156,10 +167,18 @@ describe("managed broker persistence", () => { resolveProvider: () => "provider", fetch, }); - await assertRejects(() => - persistence.output.write({ type: "text-delta", id: "message", delta: "fail" }) - ); - await assertRejects(() => persistence.output.finish({ completed: false, error: "failed" })); + const write = persistence.output.write({ + type: "text-delta", + id: "message", + delta: "fail", + }); + const finish = persistence.output.finish({ completed: true }); + const [writeResult, finishResult] = await Promise.allSettled([write, finish]); + const writeError = writeResult.status === "rejected" ? writeResult.reason : undefined; + assertEquals(writeError instanceof Error, true); + const finishError = finishResult.status === "rejected" ? finishResult.reason : undefined; + assertEquals(finishError === writeError, true); + assertEquals(calls.at(-1)?.status, "failed"); await persistence.cleanup(); }); }); diff --git a/src/agent/hosted/managed-broker-project-state.test.ts b/tests/integration/agent/managed-broker-project-state.test.ts similarity index 98% rename from src/agent/hosted/managed-broker-project-state.test.ts rename to tests/integration/agent/managed-broker-project-state.test.ts index 817b6d9082..b1b7c67b6c 100644 --- a/src/agent/hosted/managed-broker-project-state.test.ts +++ b/tests/integration/agent/managed-broker-project-state.test.ts @@ -1,7 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { createManagedBrokerProjectState } from "./managed-broker-project-state.ts"; +import { createManagedBrokerProjectState } from "#veryfront/agent/hosted/managed-broker-project-state.ts"; const definition = { id: "coder", diff --git a/tests/integration/agent/managed-node-broker.test.ts b/tests/integration/agent/managed-node-broker.test.ts new file mode 100644 index 0000000000..ac638404b8 --- /dev/null +++ b/tests/integration/agent/managed-node-broker.test.ts @@ -0,0 +1,160 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { startNodeManagedAgentBroker } from "#veryfront/agent/service/managed-node-broker.ts"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("managed node broker", () => { + it("routes every required managed surface and preserves health contracts", async () => { + const calls: Array<{ name: string; runId?: string; body: string }> = []; + const handler = (name: string) => ({ + async handle(request: Request, input: { runId?: string }) { + calls.push({ + name, + ...(input.runId ? { runId: input.runId } : {}), + body: await request.text(), + }); + return Response.json({ name, runId: input.runId }); + }, + }); + const server = await startNodeManagedAgentBroker({ + port: 0, + bindAddress: "127.0.0.1", + signals: [], + readiness: () => true, + broker: { + shutdown: () => Promise.resolve(), + closed: Promise.resolve(), + settled: Promise.resolve(), + }, + handlers: { + signedStream: handler("signed"), + durableStart: handler("start"), + agUi: handler("ag-ui"), + cancel: handler("cancel"), + resume: handler("resume"), + }, + }); + try { + assertEquals(await (await fetch(`${server.url}/liveness`)).text(), "OK"); + assertEquals(await (await fetch(`${server.url}/readiness`)).text(), "OK"); + for ( + const [method, path, body] of [ + ["POST", "/api/control-plane/runs/run-1/stream", "signed-body"], + ["POST", "/api/runs", "start-body"], + ["POST", "/api/ag-ui", "ag-ui-body"], + ["DELETE", "/api/runs/run-2", ""], + ["POST", "/api/runs/run-3/resume", "resume-direct"], + ["DELETE", "/api/control-plane/runs/run-4", ""], + ["POST", "/api/control-plane/runs/run-5/resume", "resume-control"], + ] + ) { + const response = await fetch(`${server.url}${path}`, { method, body: body || undefined }); + assertEquals(response.status, 200); + await response.body?.cancel(); + } + assertEquals(calls, [ + { name: "signed", runId: "run-1", body: "signed-body" }, + { name: "start", body: "start-body" }, + { name: "ag-ui", body: "ag-ui-body" }, + { name: "cancel", runId: "run-2", body: "" }, + { name: "resume", runId: "run-3", body: "resume-direct" }, + { name: "cancel", runId: "run-4", body: "" }, + { name: "resume", runId: "run-5", body: "resume-control" }, + ]); + } finally { + await server.stop(); + } + }); + + it("stops admission before joining handler and broker retirement", async () => { + const events: string[] = []; + const retirement = Promise.withResolvers(); + const shared = { + handle: () => Promise.resolve(new Response("handled")), + close: () => { + events.push("handler-close"); + }, + }; + const server = await startNodeManagedAgentBroker({ + port: 0, + bindAddress: "127.0.0.1", + signals: [], + readiness: () => true, + broker: { + shutdown() { + events.push("broker-shutdown"); + return Promise.resolve(); + }, + closed: Promise.resolve(), + settled: retirement.promise, + }, + handlers: { + signedStream: shared, + durableStart: shared, + agUi: shared, + cancel: shared, + resume: shared, + }, + }); + let stopped = false; + const stopping = server.stop().then(() => stopped = true); + await tick(); + assertEquals(events[0], "broker-shutdown"); + assertEquals(stopped, false); + retirement.resolve(); + await stopping; + assertEquals(stopped, true); + assertEquals(events, ["broker-shutdown", "handler-close"]); + }); + + it("rejects incomplete route configuration before binding", async () => { + await assertRejects(() => + startNodeManagedAgentBroker({ + port: 0, + signals: [], + readiness: () => true, + broker: { + shutdown: () => Promise.resolve(), + closed: Promise.resolve(), + settled: Promise.resolve(), + }, + handlers: {} as never, + }) + ); + }); + + it("keeps project loaders and agent factories outside its dependency graph", async () => { + const output = await new Deno.Command(Deno.execPath(), { + cwd: new URL("../../../", import.meta.url), + args: ["info", "--frozen", "--json", "src/agent/service/managed-node-broker.ts"], + }).output(); + assertEquals(output.code, 0, new TextDecoder().decode(output.stderr)); + const graph = JSON.parse(new TextDecoder().decode(output.stdout)) as { + roots: string[]; + modules: Array<{ specifier: string; dependencies?: Array<{ code?: { specifier: string } }> }>; + }; + const modules = new Map(graph.modules.map((module) => [module.specifier, module])); + const visited = new Set(); + const pending = [...graph.roots]; + while (pending.length) { + const specifier = pending.shift()!; + if (visited.has(specifier)) continue; + visited.add(specifier); + for (const dependency of modules.get(specifier)?.dependencies ?? []) { + if (dependency.code) pending.push(dependency.code.specifier); + } + } + const forbidden = [ + "/src/config/loader.ts", + "/src/agent/factory.ts", + "/src/tool/factory.ts", + "/src/agent/project/agent-runtime.ts", + ]; + assertEquals( + [...visited].filter((specifier) => forbidden.some((path) => specifier.endsWith(path))), + [], + ); + }); +}); diff --git a/tests/integration/ci/node-executor-coverage.test.ts b/tests/integration/ci/node-executor-coverage.test.ts index 50cbc0c806..ca5ac8d9b8 100644 --- a/tests/integration/ci/node-executor-coverage.test.ts +++ b/tests/integration/ci/node-executor-coverage.test.ts @@ -11,6 +11,70 @@ import { const root = fileURLToPath(new URL("../../../", import.meta.url)); describe("native executor source coverage", () => { + it("accepts a V8 function anchor mapped inside the final TypeScript function body", async () => { + await Deno.mkdir(`${root}/coverage`, { recursive: true }); + const directory = await makeTempDirWithOptions({ + dir: `${root}/coverage`, + prefix: "node-body-anchor-", + }); + const source = `${directory}/body-anchor.ts`; + const lcov = `${directory}/lcov.info`; + await Deno.writeTextFile( + source, + [ + "/** Typed declaration whose first executable range starts later. */", + "export function bodyMappedTarget(value: number): number {", + " if (value > 0) {", + " return value;", + " }", + " return 0;", + "}", + "", + "export const trailingTopLevel = 1;", + ].join("\n"), + ); + await Deno.writeTextFile( + lcov, + [ + "TN:", + `SF:${source}`, + "FN:2,bodyMappedTarget", + "FN:4,bodyMappedTarget", + "FNDA:1,bodyMappedTarget", + "DA:4,1", + "end_of_record", + "", + ].join("\n"), + ); + try { + const summaries = await validateNativeCoverage({ + root, + reportPath: lcov, + sourceFiles: [source], + }); + assertEquals(summaries, [{ source, linesHit: 1, linesFound: 1 }]); + await Deno.writeTextFile( + lcov, + [ + "TN:", + `SF:${source}`, + "FN:9,bodyMappedTarget", + "FNDA:1,bodyMappedTarget", + "DA:9,1", + "end_of_record", + "", + ].join("\n"), + ); + await assertRejects( + () => validateNativeCoverage({ root, reportPath: lcov, sourceFiles: [source] }), + Error, + "not mapped to original source", + ); + } finally { + await Deno.remove(directory, { recursive: true }); + } + }); + it("maps real TypeScript coverage and rejects transformed JavaScript positions", async () => { await Deno.mkdir(`${root}/coverage`, { recursive: true }); const directory = await makeTempDirWithOptions({ @@ -72,7 +136,7 @@ describe("native executor source coverage", () => { sourceFiles: [source], }); assertEquals(summaries.length, 1); - assert(summaries[0].linesHit > 0); + assert(summaries[0]!.linesHit > 0); } else { await assertRejects( () => validateNativeCoverage({ root, reportPath: lcov, sourceFiles: [source] }), From af23d6d029d40ef215f49d32d4ce53c61664701c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 17:46:02 +0200 Subject: [PATCH 089/194] fix(agent): preserve denials and independently protect promise chaining --- src/agent/hosted/executor-runtime-prepare.ts | 16 +++++++++------- src/agent/runtime/local-tool.ts | 7 ++----- src/security/private-promise.test.ts | 9 +++++++-- src/security/private-promise.ts | 7 ++++++- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 889a4cdddf..9fd14139d2 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -83,6 +83,7 @@ const objectDefineProperty = Object.defineProperty; const objectSetPrototypeOf = Object.setPrototypeOf; 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; @@ -402,12 +403,13 @@ export function createExecutorRuntimePreparation(input: Options) { localTools, })!, ]; - const deniedToolNames = [ - ...createPrivateSet([ - ...definition.deniedTools ?? [], - ...normalizeToolNames(definition.deniedTools ?? []), - ]), - ]; + 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, @@ -417,7 +419,7 @@ export function createExecutorRuntimePreparation(input: Options) { }); let allowedToolNames = intersectNames( normalizeToolNames(grant.allowedToolNames), - Array.isArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, + arrayIsArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, request.allowedToolNames === undefined ? undefined : normalizeToolNames(request.allowedToolNames), diff --git a/src/agent/runtime/local-tool.ts b/src/agent/runtime/local-tool.ts index c259671a83..2d7bfc8c96 100644 --- a/src/agent/runtime/local-tool.ts +++ b/src/agent/runtime/local-tool.ts @@ -18,11 +18,8 @@ export function markRuntimeLocalTool(tool: Tool): Tool { /** Check whether a tool must stay out of the project-wide tool registry. */ export function isRuntimeLocalTool(value: unknown): boolean { - return Boolean( - value && - typeof value === "object" && - objectGetOwnPropertyDescriptor(value, AGENT_RUNTIME_LOCAL_TOOL)?.value === true, - ); + return value !== null && typeof value === "object" && + objectGetOwnPropertyDescriptor(value, AGENT_RUNTIME_LOCAL_TOOL)?.value === true; } /** Mark a tool whose execution contract cannot consume hosted child-run overrides. */ diff --git a/src/security/private-promise.test.ts b/src/security/private-promise.test.ts index 621b0d9a7f..1f9717115f 100644 --- a/src/security/private-promise.test.ts +++ b/src/security/private-promise.test.ts @@ -3,12 +3,17 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { chainPrivatePromise, resolvePrivatePromise } from "./private-promise.ts"; describe("owned promise chains", () => { - for (const phase of ["input", "callback"] as const) { + for (const phase of ["input", "callback", "callback with copied constructor"] as const) { it(`joins the original ${phase} promise despite its own constructor and then hooks`, async () => { const work = Promise.withResolvers(); let hooks = 0; Object.defineProperties(work.promise, { - constructor: { value: function ForeignConstructor() {}, configurable: true }, + constructor: { + value: phase === "callback with copied constructor" + ? resolvePrivatePromise().constructor + : function ForeignConstructor() {}, + configurable: true, + }, then: { value: (fulfilled: (value: number) => void) => { hooks++; diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index 23a5b76eb8..7df939f20b 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -7,6 +7,7 @@ const promiseResolve = Promise.resolve; const promiseWithResolvers = Promise.withResolvers; const nativeHasInstance = Function.prototype[Symbol.hasInstance]; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; const freeze = Object.freeze; const species: typeof Symbol.species = Symbol.species; @@ -41,8 +42,12 @@ freeze(PrivatePromise); const privateThen = PrivatePromise.prototype.then; function protectPromise(promise: Promise): Promise { - if (getOwnPropertyDescriptor(promise, "constructor")?.value !== PrivatePromise) { + const constructor = getOwnPropertyDescriptor(promise, "constructor"); + if (!constructor || !hasOwn(constructor, "value") || constructor.value !== PrivatePromise) { defineOwnDataProperty(promise, "constructor", PrivatePromise); + } + const then = getOwnPropertyDescriptor(promise, "then"); + if (!then || !hasOwn(then, "value") || then.value !== privateThen) { defineOwnDataProperty(promise, "then", privateThen); } return promise; From d9cae4a691e1f25018a409c7ef2db729d6d3cfed Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 18:07:27 +0200 Subject: [PATCH 090/194] fix(agent): isolate nested facade wrappers and metadata filters --- .../hosted/chat-runtime-tool-assembly.test.ts | 30 +++++++ .../hosted/chat-runtime-tool-assembly.ts | 79 ++++++++++--------- src/agent/hosted/default-chat-runtime.ts | 14 ++-- src/agent/hosted/executor-runtime-prepare.ts | 30 ++++--- .../hosted/project-remote-tool-source.ts | 8 +- 5 files changed, 96 insertions(+), 65 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index ea55e9c0d7..31afcfbed9 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -23,6 +23,36 @@ import { prepareHostedChatRuntimeToolAssembly, } from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; +describe("private host tool metadata", () => { + it("does not read inherited short names while enforcing denials", async () => { + let reads = 0; + const definition = Object.create({ + get shortName() { + reads++; + return "hidden"; + }, + }, { + description: { value: "Synthetic tool", enumerable: true }, + inputSchema: { value: defineSchema((v) => v.object({}))(), enumerable: true }, + execute: { value: () => ({ ok: true }), enumerable: true }, + }); + const assembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + signal: new AbortController().signal, + taskContext: { agentId: "synthetic", model: "veryfront-cloud/openai/gpt-5.4" }, + instructions: "Synthetic instructions", + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + localTools: { visible: definition }, + hostToolPolicy: { allow: ["visible"] }, + allowedToolNames: ["visible"], + deniedToolNames: ["other"], + remoteToolSources: [], + }); + assertEquals(reads, 0); + assertEquals(assembly.localToolNames, ["visible"]); + assertEquals(await assembly.runtimeTools.visible?.execute({}), { ok: true }); + }); +}); + it("facaded assembly preserves project tool normalization and mutation callbacks without private transport config", async () => { const calls: Array> = []; let mutations = 0; diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index e06e4f21c6..5e1eb7c00e 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -1,4 +1,5 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; import { createRemoteMCPToolSource, @@ -51,7 +52,6 @@ import { compareStrings } from "#veryfront/utils/compare.ts"; const apply = Reflect.apply; const arrayIncludes = Array.prototype.includes; const arraySort = Array.prototype.sort; -const objectDefineProperty = Object.defineProperty; const objectEntries = Object.entries; const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const objectSetPrototypeOf = Object.setPrototypeOf; @@ -79,8 +79,7 @@ function filterValues( if (!objectHasOwn(values, index)) continue; const value = values[index]!; if (!predicate(value, index, values)) continue; - objectDefineProperty(filtered, filtered.length, { - value, + defineOwnDataProperty(filtered, filtered.length, value, { enumerable: true, configurable: true, writable: true, @@ -95,16 +94,12 @@ function mapValues( ): U[] { const mapped: U[] = []; for (let index = 0; index < values.length; index++) { - apply(objectDefineProperty, Object, [ + defineOwnDataProperty( mapped, index, - { - value: callback(values[index]!, index, values), - enumerable: true, - configurable: true, - writable: true, - }, - ]); + callback(values[index]!, index, values), + { enumerable: true, configurable: true, writable: true }, + ); } return mapped; } @@ -122,18 +117,20 @@ function recordFromEntries(entries: readonly (readonly [string, T])[]): Recor for (let index = 0; index < entries.length; index++) { const entry = entries[index]; if (entry === undefined) continue; - apply(objectDefineProperty, Object, [ + defineOwnDataProperty( result, entry[0], - { value: entry[1], enumerable: true, configurable: true, writable: true }, - ]); + entry[1], + { enumerable: true, configurable: true, writable: true }, + ); } return result; } function ownDataValue(value: HostToolSet[string], key: PropertyKey): unknown { try { - return apply(objectGetOwnPropertyDescriptor, Object, [value, key])?.value; + const descriptor = apply(objectGetOwnPropertyDescriptor, Object, [value, key]); + return descriptor && objectHasOwn(descriptor, "value") ? descriptor.value : undefined; } catch { return undefined; } @@ -310,9 +307,11 @@ function withoutDeniedHostTools( } const denied = createPrivateSet(deniedToolNames); return recordFromEntries( - filterValues(ownEntries(tools), ([toolName, tool]) => - !denied.has(toolName) && - (tool.shortName === undefined || !denied.has(tool.shortName))), + filterValues(ownEntries(tools), (entry) => { + const shortName = ownDataValue(entry[1], "shortName"); + return !denied.has(entry[0]) && + (typeof shortName !== "string" || !denied.has(shortName)); + }), ); } @@ -328,7 +327,7 @@ function withoutDeniedRemoteTool( if (!deniedToolNames?.length) { return source; } - const deny = [...deniedToolNames]; + const deny = mapValues(deniedToolNames, (name) => name); return wrapRemoteToolSourceWithMcpPolicy(source, { deny }, { deniedDetail: (toolName) => `Tool "${toolName}" is denied by the agent configuration`, }); @@ -350,9 +349,11 @@ function applyHostedHostToolPolicy( } const allowed = createPrivateSet(policy.allow); return recordFromEntries( - filterValues(ownEntries(tools), ([registeredName, tool]) => - allowed.has(registeredName) || - (tool.shortName !== undefined && allowed.has(tool.shortName))), + filterValues(ownEntries(tools), (entry) => { + const shortName = ownDataValue(entry[1], "shortName"); + return allowed.has(entry[0]) || + (typeof shortName === "string" && allowed.has(shortName)); + }), ); } @@ -384,7 +385,7 @@ function filterPostFormInputLocalTools( const blockedToolNames = createPrivateSet(["form_input", "load_skill"]); return recordFromEntries( - filterValues(ownEntries(tools), ([toolName]) => !blockedToolNames.has(toolName)), + filterValues(ownEntries(tools), (entry) => !blockedToolNames.has(entry[0])), ); } @@ -448,10 +449,10 @@ export function filterHostedChatRuntimeLocalTools(input: { const allowedToolNames = normalizeHostedRuntimeAllowedToolNames(input.allowedToolNames); const entries = filterValues( ownEntries(input.tools), - ([toolName]) => allowedToolNames ? allowedToolNames.has(toolName) : true, + (entry) => allowedToolNames ? allowedToolNames.has(entry[0]) : true, ); - return recordFromEntries(sortValues(entries, ([left], [right]) => compareStrings(left, right))); + return recordFromEntries(sortValues(entries, (left, right) => compareStrings(left[0], right[0]))); } function shouldIncludeHostedWebFetchFallback(input: { @@ -520,7 +521,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const providerNativeToolNames = getProviderNativeToolNames({ model: input.taskContext.model }); const sortedLocalToolEntries = filterValues( ownEntries(selectedLocalTools), - ([toolName]) => isIntegrationToolAllowedBySourcePolicy(toolName, input.sourceIntegrationPolicy), + (entry) => isIntegrationToolAllowedBySourcePolicy(entry[0], input.sourceIntegrationPolicy), ); if ( !hasOwn(selectedLocalTools, "web_fetch") && @@ -534,16 +535,16 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< ) { const hostedWebFetchTool = postFormInputLocalTools.web_fetch; if (hostedWebFetchTool !== undefined) { - objectDefineProperty(sortedLocalToolEntries, sortedLocalToolEntries.length, { - value: ["web_fetch", hostedWebFetchTool], - enumerable: true, - configurable: true, - writable: true, - }); + defineOwnDataProperty( + sortedLocalToolEntries, + sortedLocalToolEntries.length, + ["web_fetch", hostedWebFetchTool], + { enumerable: true, configurable: true, writable: true }, + ); } } const sortedLocalTools = recordFromEntries( - sortValues(sortedLocalToolEntries, ([left], [right]) => compareStrings(left, right)), + sortValues(sortedLocalToolEntries, (left, right) => compareStrings(left[0], right[0])), ); const localHostTools = input.traceLocalTools ? traceHostTools(sortedLocalTools, input.traceLocalTools) @@ -554,8 +555,8 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const remoteToolSources = withoutDeniedRemoteTools( "remoteToolSources" in input - ? mapValues(input.remoteToolSources, (source) => - createHostedProjectRemoteToolSource({ + ? mapValues(input.remoteToolSources, (source) => { + const sourceOptions: Parameters[0] = { source: withoutDeniedRemoteTool( wrapRemoteToolSourceWithMcpPolicy( source, @@ -571,7 +572,10 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< shouldRetryWithTool: input.shouldRetryWithRemoteTool, onProjectSwitch: input.onStudioProjectSwitch, onSteeringMutation: input.onSteeringMutation, - })) + }; + objectSetPrototypeOf(sourceOptions, null); + return createHostedProjectRemoteToolSource(sourceOptions); + }) : createHostedProjectRemoteToolSources({ authToken: input.taskContext.authToken, apiMcpUrl: input.apiMcpUrl, @@ -662,8 +666,7 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< const compatibleLocalRuntimeTools = toolLoadingMode === "deferred" ? localRuntimeTools : recordFromEntries( - filterValues(ownEntries(localRuntimeTools), ([toolName]) => - compatibleToolNames.has(toolName)), + filterValues(ownEntries(localRuntimeTools), (entry) => compatibleToolNames.has(entry[0])), ); const compatibleLocalToolNames = ownKeys(compatibleLocalRuntimeTools); const compatibleRemoteToolNames = toolLoadingMode === "deferred" diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 692850e23e..70c6e998f5 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -61,11 +61,11 @@ import type { RuntimeToolFilterConfig } from "../runtime/runtime-tool-config.ts" import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; import { runWithEffectiveSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; const apply = Reflect.apply; const TypeErrorConstructor = TypeError; const objectEntries = Object.entries; -const objectDefineProperty = Object.defineProperty; function mapOwnRecord( input: Record, @@ -76,16 +76,12 @@ function mapOwnRecord( for (let index = 0; index < entries.length; index++) { const entry = entries[index]; if (entry === undefined) continue; - apply(objectDefineProperty, Object, [ + defineOwnDataProperty( output, entry[0], - { - value: mapper(entry[0], entry[1]), - enumerable: true, - configurable: true, - writable: true, - }, - ]); + mapper(entry[0], entry[1]), + { enumerable: true, configurable: true, writable: true }, + ); } return output; } diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 9fd14139d2..e70a8cf1e1 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -1,4 +1,5 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { chainPrivatePromise as chain, createPrivateDeferred, @@ -79,7 +80,6 @@ const apply = Reflect.apply; const mapGet = Map.prototype.get; const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; -const objectDefineProperty = Object.defineProperty; const objectSetPrototypeOf = Object.setPrototypeOf; const objectEntries = Object.entries; const arrayIncludes = Array.prototype.includes; @@ -94,7 +94,7 @@ const iteratorSymbol = Symbol.iterator; function combineSignals(...signals: AbortSignal[]): AbortSignal { const inputs = createPrivateSet(signals); - objectDefineProperty(signals, iteratorSymbol, { value: () => inputs.values() }); + defineOwnDataProperty(signals, iteratorSymbol, () => inputs.values()); return apply(abortSignalAny, AbortSignalConstructor, [signals]) as AbortSignal; } @@ -103,11 +103,12 @@ function filter(values: readonly T[], predicate: (value: T) => boolean): T[] for (let index = 0; index < values.length; index++) { const value = values[index] as T; if (!predicate(value)) continue; - apply(objectDefineProperty, Object, [ + defineOwnDataProperty( filtered, filtered.length, - { value, enumerable: true, configurable: true, writable: true }, - ]); + value, + { enumerable: true, configurable: true, writable: true }, + ); } return filtered; } @@ -134,11 +135,12 @@ function selectAllowedHostTools( for (let index = 0; index < entries.length; index++) { const entry = entries[index]; if (entry === undefined || !allowed.has(entry[0])) continue; - apply(objectDefineProperty, Object, [ + defineOwnDataProperty( selected, entry[0], - { value: entry[1], enumerable: true, configurable: true, writable: true }, - ]); + entry[1], + { enumerable: true, configurable: true, writable: true }, + ); } return selected; } @@ -571,16 +573,12 @@ export function createExecutorRuntimePreparation(input: Options) { ); } } - apply(objectDefineProperty, Object, [ + defineOwnDataProperty( remoteToolSources, remoteToolSources.length, - { - value: remoteToolSource, - enumerable: true, - configurable: true, - writable: true, - }, - ]); + remoteToolSource, + { enumerable: true, configurable: true, writable: true }, + ); } const facadeAllowedToolSet = createPrivateSet(); for (let index = 0; index < allowedToolNames.length; index++) { diff --git a/src/agent/hosted/project-remote-tool-source.ts b/src/agent/hosted/project-remote-tool-source.ts index 245bd26eaa..9da50eb849 100644 --- a/src/agent/hosted/project-remote-tool-source.ts +++ b/src/agent/hosted/project-remote-tool-source.ts @@ -10,6 +10,7 @@ import { type ToolDefinition, type ToolExecutionContext, } from "#veryfront/tool"; + import { type AgentServiceMcpServerConfig, createAgentServiceRemoteMcpConfig, @@ -35,6 +36,7 @@ import { import { filterVeryfrontApiToolDefinitionsWithAccessProfile } from "./veryfront-api-tool-access.ts"; import { serverLogger } from "#veryfront/utils"; +const objectSetPrototypeOf = Object.setPrototypeOf; const logger = serverLogger.component("agent"); const REMOTE_TOOL_CATALOG_INITIAL_BACKOFF_MS = 1_000; const REMOTE_TOOL_CATALOG_MAX_BACKOFF_MS = 30_000; @@ -197,13 +199,15 @@ export function createHostedProjectRemoteToolSource( ? input.activatedRemoteToolNames : input.allowedToolNames; const resilientSource = createRunResilientRemoteToolSource(input.source); - const toolCatalog = createProjectScopedRemoteToolCatalog({ + const catalogOptions: ProjectScopedRemoteToolCatalogOptions = { source: resilientSource, defaultProjectId: input.defaultProjectId, allowedToolNames: catalogAllowedToolNames, projectScopedRemoteToolOptions: input.projectScopedRemoteToolOptions, filterToolDefinitions: input.filterToolDefinitions, - }); + }; + objectSetPrototypeOf(catalogOptions, null); + const toolCatalog = createProjectScopedRemoteToolCatalog(catalogOptions); const retryToolName = input.retryToolName ?? "update_file"; function normalizeProjectToolInput( From d8fc9a51f62effe43b349d9d7964d15cea2bcca7 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 18:15:52 +0200 Subject: [PATCH 091/194] fix(agent): enforce persistence grants and preserve completion data --- scripts/test/coverage-node-executor.mjs | 24 ++-- src/agent/conversation/durable-contracts.ts | 2 + src/agent/conversation/durable.ts | 1 + src/agent/conversation/hosted-terminal.ts | 3 + .../executor-persistence-bridge.test.ts | 108 +++++++++++++++--- .../hosted/executor-persistence-bridge.ts | 6 +- .../hosted/executor-persistence-schema.ts | 13 ++- .../hosted/managed-broker-persistence.ts | 21 +++- src/agent/service/broker-ingress.ts | 25 ++-- .../service/managed-broker-handler.test.ts | 67 ++++++++--- src/agent/service/managed-broker-handler.ts | 26 ++++- src/agent/service/managed-node-broker.ts | 12 +- .../agent/managed-broker-persistence.test.ts | 90 +++++++++------ .../agent/managed-node-broker.test.ts | 65 +++++++++++ .../ci/node-executor-coverage.test.ts | 4 +- 15 files changed, 369 insertions(+), 98 deletions(-) diff --git a/scripts/test/coverage-node-executor.mjs b/scripts/test/coverage-node-executor.mjs index 933aac18a1..eabb2c98b2 100644 --- a/scripts/test/coverage-node-executor.mjs +++ b/scripts/test/coverage-node-executor.mjs @@ -70,7 +70,11 @@ export async function validateNativeCoverage( if (match) record.lines.set(Number(match[1]), Number(match[2])); } else if (record && line.startsWith("FN:")) { const match = /^FN:(\d+),(.+)$/.exec(line); - if (match) record.functions.set(match[2], Number(match[1])); + if (match) { + const positions = record.functions.get(match[2]) ?? []; + positions.push(Number(match[1])); + record.functions.set(match[2], positions); + } } else if (record && line === "end_of_record") { records.set(record.source, record); record = undefined; @@ -101,18 +105,20 @@ export async function validateNativeCoverage( first--; } first = Math.max(1, first - 1); - const functionEndOffset = anchor && lines.slice(anchor.line).findIndex((line) => line === "}"); + const functionEndOffset = anchor && + lines.slice(anchor.line).findIndex((line) => line === "}"); const functionEnd = functionEndOffset === -1 || !anchor ? undefined : anchor.line + functionEndOffset + 1; - const mappedLine = anchor && entry.functions.get(anchor.name); + const mappedLines = anchor && entry.functions.get(anchor.name); + const hasOriginalFunctionPosition = mappedLines?.some((line) => + functionEnd !== undefined && line >= first && line <= functionEnd + ) === true; if ( - !anchor || mappedLine === undefined || mappedLine < first || - // Node LCOV can repeat a named function and place the later record at - // its first executable body range. Bound that retained record to the - // final top-level function's actual closing brace, never trailing code. - functionEnd === undefined || mappedLine > functionEnd || - [...entry.lines.keys()].some((line) => line < 1 || line > lines.length) + !anchor || !hasOriginalFunctionPosition || + [...entry.lines.keys()].some((line) => + line < 1 || line > lines.length + ) ) { throw new Error( `Native coverage is not mapped to original source: ${basename(source)}`, diff --git a/src/agent/conversation/durable-contracts.ts b/src/agent/conversation/durable-contracts.ts index 407006540b..6fe4e0efa6 100644 --- a/src/agent/conversation/durable-contracts.ts +++ b/src/agent/conversation/durable-contracts.ts @@ -452,4 +452,6 @@ export interface FinalizeConversationAgentRunInput { finishReason?: string; terminalErrorCode?: string | null; terminalErrorMessage?: string | null; + /** Explicit trusted-host transport for broker-owned finalization. */ + fetch?: typeof globalThis.fetch; } diff --git a/src/agent/conversation/durable.ts b/src/agent/conversation/durable.ts index 6ad5e3a588..e3996074d1 100644 --- a/src/agent/conversation/durable.ts +++ b/src/agent/conversation/durable.ts @@ -1398,5 +1398,6 @@ export async function finalizeConversationAgentRun( }, responseSchema: CompleteConversationRunResponseSchema, operation: "Complete canonical durable run", + fetch: input.fetch, }); } diff --git a/src/agent/conversation/hosted-terminal.ts b/src/agent/conversation/hosted-terminal.ts index 8194ad3cb5..343e9cc75c 100644 --- a/src/agent/conversation/hosted-terminal.ts +++ b/src/agent/conversation/hosted-terminal.ts @@ -136,6 +136,8 @@ export interface CreateConversationHostedTerminalAdapterOptions { fallbackModelId: string; resolveProvider: (modelId: string) => string; onTerminalState?: (terminalState: HostedLifecycleTerminalState) => Promise | void; + /** Explicit trusted-host transport for durable terminal persistence. */ + fetch?: typeof globalThis.fetch; } /** Public API contract for conversation hosted terminal adapter. */ @@ -245,6 +247,7 @@ export function createConversationHostedTerminalAdapter( ), terminalErrorCode: terminalState.terminalErrorCode, terminalErrorMessage: terminalState.terminalErrorMessage, + fetch: options.fetch, }); } catch (error) { // Allow a later dispatch to retry; keeping the flag set on failure would diff --git a/src/agent/hosted/executor-persistence-bridge.test.ts b/src/agent/hosted/executor-persistence-bridge.test.ts index 568c819ef2..72a30184b0 100644 --- a/src/agent/hosted/executor-persistence-bridge.test.ts +++ b/src/agent/hosted/executor-persistence-bridge.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { buildInvokeAgentChildRunLifecycleCustomEvent } from "#veryfront/agent/child-run/invoke-agent-child-runs.ts"; import { createExecutorChannel, type ExecutorOperation } from "../executor/channel.ts"; import { createExecutorPersistenceBroker, @@ -9,6 +10,7 @@ import { import { executorPersistenceJson, executorPersistenceOperations, + getExecutorParentRunEventsRequestSchema, getExecutorPersistenceCapabilityIdsSchema, } from "./executor-persistence-schema.ts"; @@ -23,6 +25,35 @@ const capabilityIds = { providerReplayCheckpoint: "provider-checkpoint-capability", }; const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +function parentProgressEvent( + status: "pending" | "running" | "waiting_for_tool" | "completed" | "failed" | "cancelled" = + "running", +) { + return buildInvokeAgentChildRunLifecycleCustomEvent({ + toolCallId: "tool-call-test", + childConversationId: "10000000-1000-4000-8000-100000000001", + childRunId: "child-run-test", + childMessageId: "10000000-1000-4000-8000-100000000002", + childAgentId: "child-agent-test", + status, + }); +} +const privateCheckpointEvents = [ + executorPersistenceJson({ + type: "AGENT_RUN_TOOL_EXPOSURE_CHECKPOINT", + version: 2, + loadedToolNames: ["search"], + }), + executorPersistenceJson({ + type: "AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT", + version: 1, + messageId: "message-test", + provider: "anthropic", + providerBlocks: [], + providerBlockPositions: [], + totalPartCount: 1, + }), +]; function pair(operations: ReadonlyMap, maxConcurrentCalls?: number) { const forward = new TransformStream(); @@ -49,6 +80,49 @@ function pair(operations: ReadonlyMap, maxConcurrentC } describe("executor persistence bridge", () => { + it("allows exact child progress events and rejects checkpoint event types", () => { + const request = { + capabilityId: capabilityIds.publishParentRunEvents, + sequence: 1, + events: [parentProgressEvent()], + }; + assertEquals(getExecutorParentRunEventsRequestSchema().safeParse(request).success, true); + for (const event of privateCheckpointEvents) { + assertEquals( + getExecutorParentRunEventsRequestSchema().safeParse({ ...request, events: [event] }) + .success, + false, + ); + } + }); + + it("rejects checkpoint event types before parent persistence dispatch", async () => { + let dispatches = 0; + const operations = createExecutorPersistenceBroker({ + expectedBinding: binding, + capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, + publishParentRunEvents: async () => { + dispatches++; + }, + }); + const operation = operations.get(executorPersistenceOperations.publishParentRunEvents); + if (operation?.mode !== "unary") throw new Error("missing synthetic operation"); + for (const event of privateCheckpointEvents) { + await assertRejects(() => + Promise.resolve(operation.handle({ + capabilityId: capabilityIds.publishParentRunEvents, + sequence: 1, + events: [event], + }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 1_000, + })) + ); + } + assertEquals(dispatches, 0); + }); + it("keeps later writes usable after an unsent request hits channel admission", async () => { const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -58,7 +132,7 @@ describe("executor persistence bridge", () => { expectedBinding: binding, capabilityIds: { publishParentRunEvents: "events" }, publishParentRunEvents: async (events) => { - written.push(events[0]!.type); + written.push(JSON.stringify(events[0])); if (written.length === 1) { entered.resolve(); await release.promise; @@ -72,13 +146,15 @@ describe("executor persistence bridge", () => { capabilityIds: { publishParentRunEvents: "events" }, }); try { - const first = facade.publishParentRunEvents!([{ type: "FIRST" }]); + const firstEvent = parentProgressEvent("pending"); + const thirdEvent = parentProgressEvent("completed"); + const first = facade.publishParentRunEvents!([firstEvent]); await entered.promise; - await assertRejects(() => facade.publishParentRunEvents!([{ type: "UNSENT" }])); + await assertRejects(() => facade.publishParentRunEvents!([parentProgressEvent("running")])); release.resolve(); await first; - await facade.publishParentRunEvents!([{ type: "THIRD" }]); - assertEquals(written, ["FIRST", "THIRD"]); + await facade.publishParentRunEvents!([thirdEvent]); + assertEquals(written, [JSON.stringify(firstEvent), JSON.stringify(thirdEvent)]); } finally { release.resolve(); await channels.close(); @@ -140,7 +216,7 @@ describe("executor persistence bridge", () => { capabilityIds, }); await Promise.all([ - facades.publishParentRunEvents?.([{ type: "STEP_STARTED" }]), + facades.publishParentRunEvents?.([parentProgressEvent()]), facades.toolExposureCheckpoint?.persist({ version: 2, loadedToolNames: ["search"] }), facades.providerReplayCheckpoint?.persist({ version: 1, @@ -156,7 +232,7 @@ describe("executor persistence bridge", () => { totalPartCount: 1, }), ]); - assertEquals(persisted, ["events:STEP_STARTED", "tools:search", "provider:message-1"]); + assertEquals(persisted, ["events:CUSTOM", "tools:search", "provider:message-1"]); } finally { await channels.close(); } @@ -195,7 +271,7 @@ describe("executor persistence bridge", () => { Promise.resolve(operation.handle({ capabilityId: "wrong-capability", sequence: 1, - events: [{ type: "UNAUTHORIZED" }], + events: [parentProgressEvent("pending")], }, { binding, signal: new AbortController().signal, @@ -216,8 +292,8 @@ describe("executor persistence bridge", () => { [cyclic] as unknown as Parameters>[0], ) ); - await facades.publishParentRunEvents!([{ type: "AUTHORIZED" }]); - assertEquals(persisted, ["AUTHORIZED"]); + await facades.publishParentRunEvents!([parentProgressEvent()]); + assertEquals(persisted, ["CUSTOM"]); } finally { await channels.close(); } @@ -239,7 +315,7 @@ describe("executor persistence bridge", () => { capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, }); let acknowledged = false; - const request = facades.publishParentRunEvents!([{ type: "STEP_FINISHED" }]).then(() => { + const request = facades.publishParentRunEvents!([parentProgressEvent("completed")]).then(() => { acknowledged = true; }); await entered.promise; @@ -281,7 +357,7 @@ describe("executor persistence bridge", () => { capabilityIds: { publishParentRunEvents: capabilityIds.publishParentRunEvents }, publishParentRunEvents: async (events) => { persisted.push(String(events[0]?.type)); - if (events[0]?.type === "FIRST") throw new Error("synthetic persistence failure"); + if (persisted.length === 1) throw new Error("synthetic persistence failure"); }, }); const operation = operations.get(executorPersistenceOperations.publishParentRunEvents); @@ -295,25 +371,25 @@ describe("executor persistence bridge", () => { Promise.resolve(operation.handle({ capabilityId: capabilityIds.publishParentRunEvents, sequence: 1, - events: [{ type: "FIRST" }], + events: [parentProgressEvent("pending")], }, context)) ); await assertRejects(() => Promise.resolve(operation.handle({ capabilityId: capabilityIds.publishParentRunEvents, sequence: 1, - events: [{ type: "RETRY" }], + events: [parentProgressEvent("running")], }, context)) ); assertEquals( await operation.handle({ capabilityId: capabilityIds.publishParentRunEvents, sequence: 2, - events: [{ type: "NEXT" }], + events: [parentProgressEvent("completed")], }, context), { acknowledged: true, sequence: 2 }, ); - assertEquals(persisted, ["FIRST", "NEXT"]); + assertEquals(persisted, ["CUSTOM", "CUSTOM"]); }); it("rejects malformed replay state and mismatched bindings without reserving sequence", async () => { diff --git a/src/agent/hosted/executor-persistence-bridge.ts b/src/agent/hosted/executor-persistence-bridge.ts index 0eadc34337..49bc333613 100644 --- a/src/agent/hosted/executor-persistence-bridge.ts +++ b/src/agent/hosted/executor-persistence-bridge.ts @@ -40,6 +40,10 @@ export type ExecutorPersistenceFacades = Pick< "publishParentRunEvents" | "toolExposureCheckpoint" | "providerReplayCheckpoint" >; +type ExecutorPersistencePayload = + | { events: ConversationRunEvent[] } + | { checkpoint: ToolExposureCheckpoint | ProviderReplayCheckpoint }; + function sameBinding(left: Readonly, right: Readonly): boolean { return left.allocationId === right.allocationId && left.generation === right.generation && left.invocationId === right.invocationId; @@ -212,7 +216,7 @@ export function createExecutorPersistenceFacades(options: { const request = async ( operation: string, capabilityId: string, - payload: object, + payload: ExecutorPersistencePayload, schema: Schema, ) => { options.signal?.throwIfAborted(); diff --git a/src/agent/hosted/executor-persistence-schema.ts b/src/agent/hosted/executor-persistence-schema.ts index e4cba622e7..a68b6ab898 100644 --- a/src/agent/hosted/executor-persistence-schema.ts +++ b/src/agent/hosted/executor-persistence-schema.ts @@ -1,8 +1,11 @@ import type { InferSchema, Schema } 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 { getConversationRunEventSchema } from "#veryfront/agent/conversation/run-events.ts"; import { MAX_CONVERSATION_RUN_EVENT_PAYLOAD_BYTES } from "#veryfront/agent/conversation/run-event-limits.ts"; +import { + getInvokeAgentChildRunLifecycleCustomEventSchema, + getInvokeAgentChildRunStateDeltaSchema, +} from "#veryfront/agent/child-run/invoke-agent-child-runs.ts"; import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; import { EXECUTOR_MAX_FRAME_BYTES } from "../executor/protocol.ts"; @@ -30,6 +33,12 @@ const getCapabilityRequestSchema = defineSchema((v) => sequence: getSequenceSchema(), }).strict() ); +const getExecutorParentRunEventSchema = defineSchema((v) => + v.union([ + getInvokeAgentChildRunStateDeltaSchema(), + getInvokeAgentChildRunLifecycleCustomEventSchema(), + ]) +); /** Identifiers installed by the broker; authority and run ownership are never wire fields. */ export const getExecutorPersistenceCapabilityIdsSchema = defineSchema((v) => @@ -49,7 +58,7 @@ export type ExecutorPersistenceCapabilityIds = InferSchema< export const getExecutorParentRunEventsRequestSchema = defineSchema((v) => getCapabilityRequestSchema().extend({ events: v.array( - getConversationRunEventSchema().refine((event) => + getExecutorParentRunEventSchema().refine((event) => encoder.encode(JSON.stringify(event)).byteLength <= MAX_CONVERSATION_RUN_EVENT_PAYLOAD_BYTES ), diff --git a/src/agent/hosted/managed-broker-persistence.ts b/src/agent/hosted/managed-broker-persistence.ts index 957c803970..736932748b 100644 --- a/src/agent/hosted/managed-broker-persistence.ts +++ b/src/agent/hosted/managed-broker-persistence.ts @@ -22,11 +22,16 @@ import { type ProviderReplayCheckpoint, } from "../runtime/provider-replay.ts"; import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; +import type { HostedLifecycleTerminalState } from "./lifecycle.ts"; /** Acknowledging output writes and terminal finalization for a canonical run. */ export interface ManagedBrokerOutput { write(chunk: ChatUiMessageChunk): Promise; - finish(input: { completed: boolean; error?: unknown }): Promise; + finish(input: { + completed: boolean; + error?: unknown; + metadata?: HostedLifecycleTerminalState["metadata"]; + }): Promise; } /** Create exact-run API persistence callbacks while retaining credentials in the broker. */ @@ -62,6 +67,7 @@ export function createManagedBrokerPersistence(input: { run, fallbackModelId: input.modelId, resolveProvider: input.resolveProvider, + fetch: input.fetch, }); const durableSink = createDurableRunEventSink({ mirror: durableMirror }); let tail = Promise.resolve(); @@ -137,17 +143,24 @@ export function createManagedBrokerPersistence(input: { try { if (terminalFailure.failed) { await terminal.dispatch( - resolveConversationHostedStreamErrorState(terminalFailure.error), + { + ...resolveConversationHostedStreamErrorState(terminalFailure.error), + metadata: result.metadata, + }, ); } else if (result.completed) { - await terminal.dispatch({ status: "completed" }); + await terminal.dispatch({ status: "completed", metadata: result.metadata }); } else if (result.error !== undefined) { - await terminal.dispatch(resolveConversationHostedStreamErrorState(result.error)); + await terminal.dispatch({ + ...resolveConversationHostedStreamErrorState(result.error), + metadata: result.metadata, + }); } else { await terminal.dispatch({ status: "cancelled", terminalErrorCode: "ABORTED", terminalErrorMessage: "Managed executor output was cancelled", + metadata: result.metadata, }); } } catch (terminalDispatchError) { diff --git a/src/agent/service/broker-ingress.ts b/src/agent/service/broker-ingress.ts index ba563fbf43..9c3e87cb2f 100644 --- a/src/agent/service/broker-ingress.ts +++ b/src/agent/service/broker-ingress.ts @@ -248,14 +248,16 @@ export async function parseBrokerRuntimeAgentIngress( throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); } - const executorValue = snapshotExecutorValue({ - owner, - run: invocation.run, - ...(invocation.taskId ? { taskId: invocation.taskId } : {}), - agentSource: invocation.agentSource, - ...(invocation.agentConfig ? { agentConfig: invocation.agentConfig } : {}), - input: toRuntimeRunAgentInput(parsedInbound.data), - }); + const executorValue = snapshotExecutorValue( + { + owner, + run: invocation.run, + ...(invocation.taskId ? { taskId: invocation.taskId } : {}), + agentSource: invocation.agentSource, + ...(invocation.agentConfig ? { agentConfig: invocation.agentConfig } : {}), + input: toRuntimeRunAgentInput(parsedInbound.data), + } satisfies BrokerRuntimeAgentExecutorInput, + ); for ( const token of [ inboundAuthorization, @@ -281,14 +283,15 @@ export async function parseBrokerRuntimeAgentIngress( authorization, rawBody, }), - executor: executorValue as unknown as BrokerRuntimeAgentExecutorInput, + executor: executorValue, }; } -function snapshotExecutorValue(value: unknown) { +function snapshotExecutorValue(value: T): T { const snapshot = snapshotBoundedJsonValue(value); if (!snapshot.success) throw new BrokerIngressError(400, "BROKER_INGRESS_INVALID_BODY"); - return snapshot.value; + // The bounded copy preserves the assembled, schema-validated DTO structure. + return snapshot.value as T; } function containsForwardedAuthority(value: unknown): boolean { diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index efd7d2a8d3..9a14f9f5df 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -55,6 +55,7 @@ async function request(signal?: AbortSignal) { function runtimeFixture( streamFailure = false, terminalChunk?: "error" | "finish-error", + finishWithUsage?: true, ) { const release = Promise.withResolvers(); const settled = Promise.withResolvers(); @@ -82,18 +83,37 @@ function runtimeFixture( streamCalls++; return { steps: Promise.resolve([]), - toUIMessageStream: async function* () { - await release.promise; - if (streamFailure) throw new Error("synthetic stream failure"); - if (terminalChunk === "error") { - yield { type: "error", errorText: "ordinary stream error" } as const; - return; - } - if (terminalChunk === "finish-error") { - yield { type: "finish", finishReason: "error" } as const; - return; - } - yield { type: "start", messageId: "assistant-message" } as const; + toUIMessageStream(streamOptions = {}) { + return (async function* () { + await release.promise; + if (streamFailure) throw new Error("synthetic stream failure"); + if (terminalChunk === "error") { + yield { type: "error", errorText: "ordinary stream error" } as const; + return; + } + if (terminalChunk === "finish-error") { + yield { type: "finish", finishReason: "error" } as const; + return; + } + if (finishWithUsage) { + const part = { + type: "finish" as const, + finishReason: "stop" as const, + totalUsage: { + inputTokens: 12, + outputTokens: 7, + usageCaptureStatus: "complete" as const, + }, + }; + yield { + type: "finish", + finishReason: "stop", + messageMetadata: streamOptions.messageMetadata?.({ part }), + } as const; + return; + } + yield { type: "start", messageId: "assistant-message" } as const; + })(); }, }; }, @@ -121,19 +141,25 @@ async function handler( throwingObserver?: boolean; streamFailure?: boolean; terminalChunk?: "error" | "finish-error"; + finishWithUsage?: true; missingOutput?: boolean; waitForOutput?: boolean; startError?: Error; } = {}, ) { const first = await request(options.signal); - const fixture = runtimeFixture(options.streamFailure, options.terminalChunk); + const fixture = runtimeFixture( + options.streamFailure, + options.terminalChunk, + options.finishWithUsage, + ); let prepareCalls = 0; let brokerStarts = 0; let cleanupCalls = 0; const outputChunks: string[] = []; const outputFinishes: boolean[] = []; const outputFinishErrors: unknown[] = []; + const outputFinishMetadata: unknown[] = []; const outputRelease = Promise.withResolvers(); let prepareSignal: AbortSignal | undefined; const prepareEntered = Promise.withResolvers(); @@ -180,9 +206,10 @@ async function handler( async write(chunk: { type: string }) { outputChunks.push(chunk.type); }, - async finish(outcome: { completed: boolean; error?: unknown }) { + async finish(outcome: { completed: boolean; error?: unknown; metadata?: unknown }) { outputFinishes.push(outcome.completed); outputFinishErrors.push(outcome.error); + outputFinishMetadata.push(outcome.metadata); if (options.waitForOutput) await outputRelease.promise; }, }, @@ -209,6 +236,7 @@ async function handler( outputChunks, outputFinishes, outputFinishErrors, + outputFinishMetadata, releaseOutput: outputRelease.resolve, abortExecution: () => executionController.abort(), get prepareCalls() { @@ -268,6 +296,17 @@ describe("managed broker handler", () => { } assertEquals(f.cleanupCalls, 1); }); + it("preserves detached finish usage metadata for durable finalization", async () => { + const f = await handler("detached", { finishWithUsage: true }); + assertEquals((await f.managed.handle(f.first.request)).status, 202); + f.fixture.release(); + await f.managed.close(); + assertEquals(f.outputFinishMetadata, [{ + modelId: "veryfront-cloud/openai/synthetic", + usage: { inputTokens: 12, outputTokens: 7 }, + usageCaptureStatus: "complete", + }]); + }); it("transfers detached ownership before 202 and prevents duplicate allocation", async () => { const f = await handler("detached"); const duplicateRequest = f.first.request.clone(); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index b57b0a01df..06ebddfbb8 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -6,6 +6,8 @@ import { ExecutorDiscoveryError } from "../hosted/executor-discovery-schema.ts"; import { HostedServiceAuthError } from "./auth.ts"; import { createAgUiChatUiTrackedResponse } from "../ag-ui/chat-ui-chunk-encoder.ts"; import type { AgUiRuntimeRequest } from "../runtime/ag-ui-contract.ts"; +import { buildChatStreamChunkMessageMetadata } from "../../chat/chat-ui-message-helpers.ts"; +import type { HostedLifecycleTerminalState } from "../hosted/lifecycle.ts"; import type { ManagedExecutorRuntime, ManagedExecutorStartInput, @@ -335,9 +337,30 @@ async function runDetached( await runtime.runOwned(async () => { let streamCompleted = false; let failure: unknown; + let terminalMetadata: HostedLifecycleTerminalState["metadata"]; try { const result = await runtime.agent.stream({ messages, abortSignal: signal }); - for await (const chunk of result.toUIMessageStream()) { + for await ( + const chunk of result.toUIMessageStream({ + messageMetadata({ part }) { + const metadata = buildChatStreamChunkMessageMetadata({ + agentId: runtime.definition.id, + agentName: runtime.definition.name, + agentAvatarUrl: runtime.definition.avatarUrl, + modelId: runtime.modelId, + part: { type: part.type, totalUsage: part.totalUsage }, + }); + terminalMetadata = { + modelId: metadata.modelId, + ...(metadata.usage ? { usage: metadata.usage } : {}), + ...(metadata.usageCaptureStatus + ? { usageCaptureStatus: metadata.usageCaptureStatus } + : {}), + }; + return metadata; + }, + }) + ) { await output.write(chunk); if (chunk.type === "error" && failure === undefined) { failure = new Error(chunk.errorText || "Agent stream failed"); @@ -357,6 +380,7 @@ async function runDetached( await output.finish({ completed: streamCompleted, ...(failure === undefined || signal.aborted ? {} : { error: failure }), + ...(terminalMetadata ? { metadata: terminalMetadata } : {}), }); } }); diff --git a/src/agent/service/managed-node-broker.ts b/src/agent/service/managed-node-broker.ts index 2755cb9323..2d7666f701 100644 --- a/src/agent/service/managed-node-broker.ts +++ b/src/agent/service/managed-node-broker.ts @@ -40,7 +40,13 @@ export async function startNodeManagedAgentBroker(options: { let shutdown: Promise | undefined; const beginShutdown = () => { shuttingDown = true; - shutdown ??= options.broker.shutdown(); + if (!shutdown) { + try { + shutdown = Promise.resolve(options.broker.shutdown()); + } catch (error) { + shutdown = Promise.reject(error); + } + } void shutdown.catch(() => {}); return shutdown; }; @@ -75,7 +81,9 @@ export async function startNodeManagedAgentBroker(options: { beginShutdown(), options.broker.closed, options.broker.settled, - ...[...new Set(handlers)].map((handler) => handler.close?.()), + ...[...new Set(handlers)].map((handler) => + Promise.resolve().then(() => handler.close?.()) + ), ]); const failed = results.find((result) => result.status === "rejected"); if (failed?.status === "rejected") throw failed.reason; diff --git a/tests/integration/agent/managed-broker-persistence.test.ts b/tests/integration/agent/managed-broker-persistence.test.ts index 1506b62fdd..ca183882e1 100644 --- a/tests/integration/agent/managed-broker-persistence.test.ts +++ b/tests/integration/agent/managed-broker-persistence.test.ts @@ -45,43 +45,53 @@ describe("managed broker persistence", () => { it("persists output, audit, parent events, checkpoints, and terminal completion", async () => { const calls: Record[] = []; const fetch = successfulFetch(calls); - await withMockFetch(fetch, async () => { - const persistence = createManagedBrokerPersistence({ - apiUrl: "https://api.example.test", - runEventToken: "run-event-token", - run, - modelId: "veryfront-cloud/openai/synthetic", - resolveProvider: () => "openai", - fetch, - }); - await persistence.output.write({ type: "text-delta", id: "message", delta: "hello" }); - await persistence.modelRunEventSink({ - type: "AGENT_RUN_MODEL_CALL_CONTEXT", - messages: [], - tools: [], - }); - await persistence.publishParentRunEvents([{ type: "STEP_STARTED" }]); - await persistence.persistToolExposureCheckpoint({ - version: 2, - loadedToolNames: ["search"], - }); - await persistence.persistProviderReplayCheckpoint({ - version: 1, - messageId, - provider: "anthropic", - providerBlocks: [{ - type: "provider-block", + await withMockFetch( + () => Promise.reject(new Error("external fetch must not be used")), + async () => { + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "veryfront-cloud/openai/synthetic", + resolveProvider: () => "openai", + fetch, + }); + await persistence.output.write({ type: "text-delta", id: "message", delta: "hello" }); + await persistence.modelRunEventSink({ + type: "AGENT_RUN_MODEL_CALL_CONTEXT", + messages: [], + tools: [], + }); + await persistence.publishParentRunEvents([{ type: "STEP_STARTED" }]); + await persistence.persistToolExposureCheckpoint({ + version: 2, + loadedToolNames: ["search"], + }); + await persistence.persistProviderReplayCheckpoint({ + version: 1, + messageId, provider: "anthropic", - block: { type: "redacted_thinking", data: "synthetic" }, - }], - providerBlockPositions: [0], - providerMessageBlockCounts: [1], - totalPartCount: 1, - }); - await persistence.output.finish({ completed: true }); - await assertRejects(() => persistence.publishParentRunEvents([{ type: "STEP_FINISHED" }])); - await persistence.cleanup(); - }); + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: "synthetic" }, + }], + providerBlockPositions: [0], + providerMessageBlockCounts: [1], + totalPartCount: 1, + }); + await persistence.output.finish({ + completed: true, + metadata: { + modelId: "veryfront-cloud/openai/synthetic", + usage: { inputTokens: 12, outputTokens: 7, cachedInputTokens: 3 }, + usageCaptureStatus: "complete", + }, + }); + await assertRejects(() => persistence.publishParentRunEvents([{ type: "STEP_FINISHED" }])); + await persistence.cleanup(); + }, + ); const events = calls.flatMap((call) => Array.isArray(call.events) ? call.events : []); assertEquals(events.some((event) => event.type === "TEXT_MESSAGE_CONTENT"), true); assertEquals(events.some((event) => event.type === "AGENT_RUN_MODEL_CALL_CONTEXT"), true); @@ -92,6 +102,14 @@ describe("managed broker persistence", () => { true, ); assertEquals(calls.at(-1)?.status, "completed"); + assertEquals(calls.at(-1)?.metadata, { + provider: "openai", + model: "veryfront-cloud/openai/synthetic", + inputTokens: 12, + outputTokens: 7, + usageCaptureStatus: "complete", + finishReason: "stop", + }); }); it("retains a queued cancellation finish until the original output write settles", async () => { diff --git a/tests/integration/agent/managed-node-broker.test.ts b/tests/integration/agent/managed-node-broker.test.ts index ac638404b8..3039e9a8bb 100644 --- a/tests/integration/agent/managed-node-broker.test.ts +++ b/tests/integration/agent/managed-node-broker.test.ts @@ -109,6 +109,71 @@ describe("managed node broker", () => { assertEquals(events, ["broker-shutdown", "handler-close"]); }); + for (const shutdownThrows of [false, true]) { + it(`joins retirement after synchronous close failure (shutdown throws: ${shutdownThrows})`, async () => { + const failure = new Error("synthetic close failure"); + const retirement = Promise.withResolvers(); + const firstClosed = Promise.withResolvers(); + let secondClosed = false; + const first = { + handle: () => new Response("handled"), + close: () => { + firstClosed.resolve(); + throw failure; + }, + }; + const second = { + handle: () => new Response("handled"), + close: () => { + secondClosed = true; + return retirement.promise; + }, + }; + const server = await startNodeManagedAgentBroker({ + port: 0, + bindAddress: "127.0.0.1", + signals: [], + readiness: () => true, + broker: { + shutdown: () => { + if (shutdownThrows) throw failure; + return Promise.resolve(); + }, + closed: Promise.resolve(), + settled: retirement.promise, + }, + handlers: { + signedStream: first, + durableStart: second, + agUi: second, + cancel: second, + resume: second, + }, + }); + let stopped = false; + const stopping = server.stop().then( + () => { + stopped = true; + return undefined; + }, + (error: unknown) => { + stopped = true; + return error; + }, + ); + try { + await Promise.race([firstClosed.promise, stopping]); + assertEquals(secondClosed, true); + assertEquals(stopped, false); + retirement.resolve(); + assertEquals(await stopping, failure); + } finally { + retirement.resolve(); + await stopping; + } + }); + } + it("rejects incomplete route configuration before binding", async () => { await assertRejects(() => startNodeManagedAgentBroker({ diff --git a/tests/integration/ci/node-executor-coverage.test.ts b/tests/integration/ci/node-executor-coverage.test.ts index ca5ac8d9b8..6e52504412 100644 --- a/tests/integration/ci/node-executor-coverage.test.ts +++ b/tests/integration/ci/node-executor-coverage.test.ts @@ -11,7 +11,7 @@ import { const root = fileURLToPath(new URL("../../../", import.meta.url)); describe("native executor source coverage", () => { - it("accepts a V8 function anchor mapped inside the final TypeScript function body", async () => { + it("retains an original function anchor when generated coordinates repeat its name", async () => { await Deno.mkdir(`${root}/coverage`, { recursive: true }); const directory = await makeTempDirWithOptions({ dir: `${root}/coverage`, @@ -38,8 +38,8 @@ describe("native executor source coverage", () => { [ "TN:", `SF:${source}`, - "FN:2,bodyMappedTarget", "FN:4,bodyMappedTarget", + "FN:1,bodyMappedTarget", "FNDA:1,bodyMappedTarget", "DA:4,1", "end_of_record", From 452f403dca082538b787ffd66ebec873e43d5982 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 18:27:59 +0200 Subject: [PATCH 092/194] fix(agent): snapshot execution grants and checkpoint facades --- .../hosted/executor-runtime-prepare.test.ts | 48 +++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 28 +++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 3cbd7d8998..6790d90c40 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -592,6 +592,54 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + it("ignores inherited initial checkpoint state during canonical preparation", async () => { + let inheritedReads = 0; + type CheckpointFacade = NonNullable; + const checkpoint: CheckpointFacade = Object.create({ + get initial() { + inheritedReads++; + return { version: 1, loadedToolNames: [] }; + }, + }, { + persist: { + value: () => Promise.resolve(), + enumerable: true, + }, + }); + let calls = 0; + const f = fixture({ + grant: { + ...grant, + allowedToolNames: ["visible"], + hostToolFacadeIds: ["local"], + execution: { + kind: "canonical", + projectId: null, + conversationId: "synthetic-conversation", + runId: "synthetic-run", + messageId: "synthetic-message", + providerReplay: "disabled", + }, + }, + facades: { + hostTools: new Map([["local", { visible: syntheticHostTool() }]]), + toolExposureCheckpoint: checkpoint, + publishParentRunEvents: () => Promise.resolve(), + resolveModelRuntime: () => ({ + ...model, + doStream: () => finishStream(calls++ === 0 ? "visible" : undefined), + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(inheritedReads, 0); + assertEquals(calls, 2); + } finally { + await f.owner.close(); + } + }); + it("preserves a granted provider-selected local web fetch fallback", async () => { let visible: string[] = []; const f = fixture({ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index e70a8cf1e1..6858fcaae9 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -81,6 +81,7 @@ const mapGet = Map.prototype.get; const mapHas = Map.prototype.has; const hasOwn = Object.hasOwn; const objectSetPrototypeOf = Object.setPrototypeOf; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const objectEntries = Object.entries; const arrayIncludes = Array.prototype.includes; const arrayIsArray = Array.isArray; @@ -209,8 +210,31 @@ function snapshotGrant( ) || !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 = 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 sameBinding(left: ExecutorBinding, right: ExecutorBinding) { return left.allocationId === right.allocationId && left.generation === right.generation && left.invocationId === right.invocationId; @@ -238,10 +262,14 @@ export function createExecutorRuntimePreparation(input: Options) { 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 facades: ExecutorRuntimeFacades = { ...input.facades, 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(); From 906a728fbe5e61e6a0329e5cf0ad58cdf92fdf62 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 18:31:42 +0200 Subject: [PATCH 093/194] fix(agent): preserve curated stream failure classifications --- .../service/managed-broker-handler.test.ts | 26 +++++++++++++++++-- src/agent/service/managed-broker-handler.ts | 10 +++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index 9a14f9f5df..20070e9a32 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -8,6 +8,7 @@ import type { } from "../hosted/managed-executor-broker.ts"; import { createManagedBrokerHandler } from "./managed-broker-handler.ts"; import { ExecutorAgentError } from "../hosted/executor-agent-schema.ts"; +import { resolveConversationHostedStreamErrorState } from "../conversation/hosted-terminal.ts"; const projectId = "00000000-0000-4000-8000-000000000005"; const userId = "00000000-0000-4000-8000-000000000006"; @@ -54,7 +55,7 @@ async function request(signal?: AbortSignal) { function runtimeFixture( streamFailure = false, - terminalChunk?: "error" | "finish-error", + terminalChunk?: "error" | "coded-error" | "finish-error", finishWithUsage?: true, ) { const release = Promise.withResolvers(); @@ -91,6 +92,14 @@ function runtimeFixture( yield { type: "error", errorText: "ordinary stream error" } as const; return; } + if (terminalChunk === "coded-error") { + yield { + type: "error", + errorText: "INSUFFICIENT_CREDITS", + code: "INSUFFICIENT_CREDITS", + } as const; + return; + } if (terminalChunk === "finish-error") { yield { type: "finish", finishReason: "error" } as const; return; @@ -140,7 +149,7 @@ async function handler( waitForAuthorization?: boolean; throwingObserver?: boolean; streamFailure?: boolean; - terminalChunk?: "error" | "finish-error"; + terminalChunk?: "error" | "coded-error" | "finish-error"; finishWithUsage?: true; missingOutput?: boolean; waitForOutput?: boolean; @@ -353,6 +362,19 @@ describe("managed broker handler", () => { } }); + it("preserves a validated executor error chunk through durable terminal classification", async () => { + const f = await handler("detached", { terminalChunk: "coded-error" }); + assertEquals((await f.managed.handle(f.first.request)).status, 202); + f.fixture.release(); + await f.managed.close(); + + assertEquals(resolveConversationHostedStreamErrorState(f.outputFinishErrors[0]), { + status: "failed", + terminalErrorCode: "INSUFFICIENT_CREDITS", + terminalErrorMessage: "Insufficient AI credits", + }); + }); + it("finalizes an aborted detached execution as cancelled instead of failed", async () => { const f = await handler("detached"); assertEquals((await f.managed.handle(f.first.request)).status, 202); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index 06ebddfbb8..f7f44977ed 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -1,6 +1,9 @@ import type { HostedChatRuntimeStreamInput } from "../hosted/chat-runtime-contract.ts"; import type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; -import { ExecutorAgentError } from "../hosted/executor-agent-schema.ts"; +import { + ExecutorAgentError, + getExecutorAgentFailureCodeSchema, +} from "../hosted/executor-agent-schema.ts"; import { ExecutorRuntimePreparationError } from "../hosted/executor-runtime-prepare-schema.ts"; import { ExecutorDiscoveryError } from "../hosted/executor-discovery-schema.ts"; import { HostedServiceAuthError } from "./auth.ts"; @@ -363,7 +366,10 @@ async function runDetached( ) { await output.write(chunk); if (chunk.type === "error" && failure === undefined) { - failure = new Error(chunk.errorText || "Agent stream failed"); + const code = getExecutorAgentFailureCodeSchema().safeParse(chunk.code); + failure = code.success + ? new ExecutorAgentError(code.data) + : new Error(chunk.errorText || "Agent stream failed"); } else if ( chunk.type === "finish" && chunk.finishReason === "error" && failure === undefined ) { From 92a84044a7bbefa51b1900acf0ccdb451d2ef03b Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 18:49:22 +0200 Subject: [PATCH 094/194] fix(agent): retain audit persistence beyond caller deadlines --- .../hosted/managed-broker-persistence.ts | 18 ++++- .../agent/managed-broker-persistence.test.ts | 68 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/managed-broker-persistence.ts b/src/agent/hosted/managed-broker-persistence.ts index 736932748b..e3e7b4a48f 100644 --- a/src/agent/hosted/managed-broker-persistence.ts +++ b/src/agent/hosted/managed-broker-persistence.ts @@ -23,6 +23,7 @@ import { } from "../runtime/provider-replay.ts"; import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; import type { HostedLifecycleTerminalState } from "./lifecycle.ts"; +import type { ConversationRunChunkMirror } from "../conversation/run-chunk-mirror.ts"; /** Acknowledging output writes and terminal finalization for a canonical run. */ export interface ManagedBrokerOutput { @@ -69,7 +70,21 @@ export function createManagedBrokerPersistence(input: { resolveProvider: input.resolveProvider, fetch: input.fetch, }); - const durableSink = createDurableRunEventSink({ mirror: durableMirror }); + let retainedPersistenceTail = Promise.resolve(); + const retainOriginalPersistence = (operation: Promise): Promise => { + const settled = operation.then(() => undefined, () => undefined); + retainedPersistenceTail = Promise.all([retainedPersistenceTail, settled]).then(() => undefined); + return operation; + }; + const durableSinkMirror: ConversationRunChunkMirror = { + ...(durableMirror.timing ? { timing: durableMirror.timing } : {}), + handleChunk: (chunk) => durableMirror.handleChunk(chunk), + appendEvents: (events) => durableMirror.appendEvents(events), + flush: (options) => retainOriginalPersistence(durableMirror.flush(options)), + getSnapshot: () => durableMirror.getSnapshot(), + dispose: () => durableMirror.dispose(), + }; + const durableSink = createDurableRunEventSink({ mirror: durableSinkMirror }); let tail = Promise.resolve(); let failure: unknown; let failed = false; @@ -175,6 +190,7 @@ export function createManagedBrokerPersistence(input: { if (cleaned) return; cleaned = true; await tail; + await retainedPersistenceTail; durableMirror.dispose(); } return { diff --git a/tests/integration/agent/managed-broker-persistence.test.ts b/tests/integration/agent/managed-broker-persistence.test.ts index ca183882e1..d158c9ef39 100644 --- a/tests/integration/agent/managed-broker-persistence.test.ts +++ b/tests/integration/agent/managed-broker-persistence.test.ts @@ -3,6 +3,7 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { createManagedBrokerPersistence } from "#veryfront/agent/hosted/managed-broker-persistence.ts"; +import { FakeTime } from "#std/testing/time"; const conversationId = "00000000-0000-4000-8000-000000000001"; const messageId = "00000000-0000-4000-8000-000000000002"; @@ -201,6 +202,73 @@ describe("managed broker persistence", () => { }); }); + it("retains a noncooperative model audit append after its deadline failure", async () => { + using time = new FakeTime(); + const appendEntered = Promise.withResolvers(); + const appendRelease = Promise.withResolvers(); + const calls: Record[] = []; + const fallback = successfulFetch(calls); + let delayAuditAppend = true; + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + if (delayAuditAppend && Array.isArray(body.events)) { + delayAuditAppend = false; + appendEntered.resolve(); + return await appendRelease.promise; + } + return await fallback(input, init); + }; + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "model", + resolveProvider: () => "provider", + fetch, + }); + const audit = persistence.modelRunEventSink({ + type: "AGENT_RUN_MODEL_CALL_CONTEXT", + messages: [], + tools: [], + }); + await appendEntered.promise; + + time.tick(30_000); + const auditResult = await Promise.allSettled([audit]); + const auditError = auditResult[0]?.status === "rejected" ? auditResult[0].reason : undefined; + assertEquals(auditError instanceof Error, true); + assertEquals((auditError as Error).message, "Durable run event persistence timed out"); + + const finishResult = await Promise.allSettled([ + persistence.output.finish({ completed: false, error: auditError }), + ]); + assertEquals(finishResult[0]?.status, "rejected"); + assertEquals( + finishResult[0]?.status === "rejected" ? finishResult[0].reason : undefined, + auditError, + ); + assertEquals(calls.at(-1)?.status, "failed"); + + let cleanupSettled = false; + const cleanup = persistence.cleanup().then(() => cleanupSettled = true); + for (let index = 0; index < 20; index += 1) await Promise.resolve(); + assertEquals(cleanupSettled, false); + + appendRelease.resolve(Response.json({ + latest_event_id: 1, + latest_external_event_sequence: 1, + appended_count: 1, + run: { + run_id: run.runId, + conversation_id: conversationId, + latest_event_id: 1, + latest_external_event_sequence: 1, + }, + })); + await cleanup; + assertEquals(cleanupSettled, true); + }); + it("persists a failed terminal outcome for executor output errors", async () => { const calls: Record[] = []; const fetch = successfulFetch(calls); From d35e6d0b61497ddf70bbb2d4df81cf0d9ea130dd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 19:03:55 +0200 Subject: [PATCH 095/194] fix(agent): validate own definition data and isolate task context --- src/agent/hosted/executor-discovery-schema.ts | 53 ++++++++++-- src/agent/hosted/executor-discovery.test.ts | 20 +++++ src/agent/hosted/executor-runtime-prepare.ts | 6 +- src/agent/project/agent-runtime.test.ts | 22 +++++ src/agent/project/agent-runtime.ts | 85 ++++++++++++------- src/agent/runtime/agent-definition.ts | 8 +- 6 files changed, 152 insertions(+), 42 deletions(-) diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts index f79ec8f7ed..9391088911 100644 --- a/src/agent/hosted/executor-discovery-schema.ts +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -1,3 +1,4 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; import { defineError, snapshotVeryfrontError, VeryfrontError } from "#veryfront/errors/types.ts"; @@ -5,6 +6,44 @@ import { getRuntimeAgentMarkdownDefinitionSchema } from "#veryfront/agent/runtim import { executorAgentJson } from "./executor-agent-schema.ts"; import { hasControlCharacters, isWellFormedUtf16 } from "#veryfront/skill/string-safety.ts"; +const objectCreate = Object.create; +const objectKeys = Object.keys; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectHasOwn = Object.hasOwn; +const arrayIsArray = Array.isArray; + +function snapshotDiscoveryRecords( + value: unknown, + budget = { remaining: 100_000 }, + depth = 0, +): unknown { + if (--budget.remaining < 0 || depth > 64) throw new Error("Invalid discovery data"); + if (value === null || typeof value !== "object") return value; + const array = arrayIsArray(value); + if (array && value.length > 100_000) throw new Error("Invalid discovery data"); + const result = array ? [] : objectCreate(null); + const keys = objectKeys(value); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const descriptor = objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !objectHasOwn(descriptor, "value")) { + throw new Error("Invalid discovery accessor"); + } + defineOwnDataProperty( + result, + key, + snapshotDiscoveryRecords(descriptor.value, budget, depth + 1), + { + enumerable: true, + configurable: true, + writable: true, + }, + ); + } + if (array) result.length = value.length; + return result; +} + export const EXECUTOR_DISCOVERY_MAX_AGENTS = 256; export const EXECUTOR_DISCOVERY_MAX_DEFINITION_BYTES = 64 * 1024; const statuses = { @@ -165,13 +204,15 @@ export const getExecutorAgentDescribeResultSchema = defineSchema((v) => ); export function parseDiscoveryData(schema: Schema, value: unknown, output = false): T { - const result = schema.safeParse(value); - if (!result.success) { - throw new ExecutorDiscoveryError( - output ? "EXECUTOR_DISCOVERY_INVALID_OUTPUT" : "EXECUTOR_DISCOVERY_INVALID_INPUT", - ); + try { + const result = schema.safeParse(snapshotDiscoveryRecords(value)); + if (result.success) return snapshotDiscoveryRecords(result.data) as T; + } catch { + // Snapshot and validation failures share the fixed boundary diagnostic. } - return result.data; + throw new ExecutorDiscoveryError( + output ? "EXECUTOR_DISCOVERY_INVALID_OUTPUT" : "EXECUTOR_DISCOVERY_INVALID_INPUT", + ); } export function discoverySuccess(value: unknown): JsonValue { diff --git a/src/agent/hosted/executor-discovery.test.ts b/src/agent/hosted/executor-discovery.test.ts index f8825ea657..5f7fadfcff 100644 --- a/src/agent/hosted/executor-discovery.test.ts +++ b/src/agent/hosted/executor-discovery.test.ts @@ -11,8 +11,10 @@ import { createExecutorDiscovery, type ExecutorDiscoveryBackend } from "./execut import { EXECUTOR_DISCOVERY_MAX_AGENTS, ExecutorDiscoveryError, + getExecutorAgentDefinitionSchema, getExecutorAgentDescribeResultSchema, getExecutorDiscoveryResultSchema, + parseDiscoveryData, } from "./executor-discovery-schema.ts"; const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; @@ -96,6 +98,24 @@ async function call( } describe("executor discovery operations", () => { + it("validates only own definition fields without reading inherited selectors", () => { + let reads = 0; + const definition = Object.create({ + get tools() { + reads++; + return true; + }, + }, { + id: { value: "coder", enumerable: true }, + name: { value: "Coder", enumerable: true }, + description: { value: "Synthetic", enumerable: true }, + instructions: { value: "Synthetic instructions", enumerable: true }, + }); + const parsed = parseDiscoveryData(getExecutorAgentDefinitionSchema(), definition, true); + assertEquals(reads, 0); + assertEquals(parsed.tools, undefined); + }); + it("is lazy, exposes only metadata operations, and retains the runtime locally", async () => { const f = fixture(); try { diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 6858fcaae9..7278d388f8 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -32,6 +32,7 @@ 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 { @@ -389,11 +390,13 @@ export function createExecutorRuntimePreparation(input: Options) { } const operation = privateMapGet(input.discovery.operations, "agent.describe"); if (operation?.mode !== "unary") refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - const described = getExecutorAgentDescribeResultSchema().parse( + const described = parseDiscoveryData( + getExecutorAgentDescribeResultSchema(), await chain( resolvePrivatePromise(), () => operation.handle({ agentId: request.agentId }, context), ), + true, ); if ( !described.ok || described.value.definition.id !== grant.agentId || @@ -525,6 +528,7 @@ export function createExecutorRuntimePreparation(input: Options) { ? { parentRunId: execution.runId, parentMessageId: execution.messageId } : {}), }; + objectSetPrototypeOf(taskContext, null); const options: PreparedHostedRuntimeAgentOptions["options"] = { ...execution, agentId: definition.id, diff --git a/src/agent/project/agent-runtime.test.ts b/src/agent/project/agent-runtime.test.ts index 0ab827a22c..2320862af3 100644 --- a/src/agent/project/agent-runtime.test.ts +++ b/src/agent/project/agent-runtime.test.ts @@ -260,6 +260,28 @@ Deno.test("project agent runtime serializes scoped delegates and first-party MCP }); describe("project agent runtime tool denials", () => { + it("projects optional configuration only from own fields", async () => { + const original = agent({ + id: "own-config", + system: "Synthetic instructions", + model: "openai/synthetic", + tools: {}, + }); + let reads = 0; + const config = { ...original.config }; + delete config.tools; + Object.setPrototypeOf(config, { + get tools() { + reads++; + return true; + }, + }); + const wrapped: typeof original = Object.create(original, { config: { value: config } }); + const definition = await createRuntimeAgentDefinitionFromAgent(wrapped); + assertEquals(reads, 0); + assertEquals(definition.tools, undefined); + }); + it("project agent runtime preserves code agent delegate denials", async () => { const coordinator = agent({ id: "restricted-coordinator", diff --git a/src/agent/project/agent-runtime.ts b/src/agent/project/agent-runtime.ts index 66fca07270..39f889c01f 100644 --- a/src/agent/project/agent-runtime.ts +++ b/src/agent/project/agent-runtime.ts @@ -31,6 +31,31 @@ import { } from "#veryfront/integrations/source-policy-context.ts"; import { CONFIG_INVALID } from "#veryfront/errors"; import { compareStrings } from "#veryfront/utils/compare.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const objectSetPrototypeOf = Object.setPrototypeOf; +const objectEntries = Object.entries; +const arraySort = Array.prototype.sort; +const apply = Reflect.apply; + +function selectedConfigToolNames( + tools: Exclude, + denied: boolean, +): string[] { + const entries = objectEntries(tools); + const names: string[] = []; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + if ((entry[1] === false) === denied) { + defineOwnDataProperty(names, names.length, entry[0], { + enumerable: true, + configurable: true, + writable: true, + }); + } + } + return apply(arraySort, names, [compareStrings]) as string[]; +} /** Public API contract for project agent runtime agent source. */ export type ProjectAgentRuntimeAgentSource = "auto" | "code" | "markdown"; @@ -81,9 +106,7 @@ function resolveAgentToolNames(tools: AgentConfig["tools"]): true | string[] | u return undefined; } - const names = Object.entries(tools) - .flatMap(([name, value]) => value === false ? [] : [name]) - .sort(compareStrings); + const names = selectedConfigToolNames(tools, false); return names.length > 0 ? names : undefined; } @@ -98,9 +121,7 @@ function resolveAgentDeniedToolNames(tools: AgentConfig["tools"]): string[] | un return undefined; } - const names = Object.entries(tools) - .flatMap(([name, value]) => value === false ? [name] : []) - .sort(compareStrings); + const names = selectedConfigToolNames(tools, true); return names.length > 0 ? names : undefined; } @@ -112,7 +133,10 @@ function resolveSerializableMcpServers( return undefined; } - return mcpServers.map((server) => { + const serialized: RuntimeAgentMcpServerConfig[] = []; + for (let index = 0; index < mcpServers.length; index++) { + const server = { ...mcpServers[index]! }; + objectSetPrototypeOf(server, null); if ("transport" in server) { throw CONFIG_INVALID.create({ detail: @@ -121,12 +145,13 @@ function resolveSerializableMcpServers( }); } - return { + defineOwnDataProperty(serialized, serialized.length, { kind: server.kind, ...(server.id === undefined ? {} : { id: server.id }), ...(server.toolPolicy === undefined ? {} : { toolPolicy: server.toolPolicy }), - }; - }); + }, { enumerable: true, configurable: true, writable: true }); + } + return serialized; } /** Clear project agent runtime registries. */ @@ -208,37 +233,31 @@ export async function createRuntimeAgentDefinitionFromAgent( if (markdownDefinition) { return markdownDefinition; } - const toolNames = resolveAgentToolNames(runtimeAgent.config.tools); - const deniedToolNames = resolveAgentDeniedToolNames(runtimeAgent.config.tools); - const mcpServers = resolveSerializableMcpServers(runtimeAgent.config.mcpServers); - const system = await resolveAgentSystem(runtimeAgent.config.system); + const config = { ...runtimeAgent.config }; + objectSetPrototypeOf(config, null); + const toolNames = resolveAgentToolNames(config.tools); + const deniedToolNames = resolveAgentDeniedToolNames(config.tools); + const mcpServers = resolveSerializableMcpServers(config.mcpServers); + const system = await resolveAgentSystem(config.system); return { id: runtimeAgent.id, - name: runtimeAgent.config.name ?? runtimeAgent.id, - description: runtimeAgent.config.description ?? "", - ...(runtimeAgent.config.avatarUrl ?? runtimeAgent.config.avatar_url - ? { avatarUrl: runtimeAgent.config.avatarUrl ?? runtimeAgent.config.avatar_url } + name: config.name ?? runtimeAgent.id, + description: config.description ?? "", + ...(config.avatarUrl ?? config.avatar_url + ? { avatarUrl: config.avatarUrl ?? config.avatar_url } : {}), instructions: typeof system === "string" ? system : flattenSystemInstructions(system), ...(typeof system === "string" ? {} : { system }), - model: runtimeAgent.config.model, - ...(runtimeAgent.config.temperature === undefined - ? {} - : { temperature: runtimeAgent.config.temperature }), - ...(runtimeAgent.config.thinking === undefined - ? {} - : { thinking: runtimeAgent.config.thinking }), - maxSteps: runtimeAgent.config.maxSteps, - ...(runtimeAgent.config.providerTools - ? { providerTools: runtimeAgent.config.providerTools } - : {}), - ...(runtimeAgent.config.skills === undefined ? {} : { skills: runtimeAgent.config.skills }), + model: config.model, + ...(config.temperature === undefined ? {} : { temperature: config.temperature }), + ...(config.thinking === undefined ? {} : { thinking: config.thinking }), + maxSteps: config.maxSteps, + ...(config.providerTools ? { providerTools: config.providerTools } : {}), + ...(config.skills === undefined ? {} : { skills: config.skills }), ...(toolNames === undefined ? {} : { tools: toolNames }), ...(deniedToolNames === undefined ? {} : { deniedTools: deniedToolNames }), - ...(runtimeAgent.config.delegates === undefined - ? {} - : { delegates: runtimeAgent.config.delegates }), + ...(config.delegates === undefined ? {} : { delegates: config.delegates }), ...(mcpServers === undefined ? {} : { mcpServers }), }; } diff --git a/src/agent/runtime/agent-definition.ts b/src/agent/runtime/agent-definition.ts index 899840d727..56d58b3302 100644 --- a/src/agent/runtime/agent-definition.ts +++ b/src/agent/runtime/agent-definition.ts @@ -8,6 +8,8 @@ import type { RuntimeSkillDefinition } from "./skill-metadata.ts"; import { normalizeAgentDelegateIds } from "./agent-delegation-names.ts"; import { CONFIG_INVALID } from "#veryfront/errors"; +const objectSetPrototypeOf = Object.setPrototypeOf; + /** Zod schema for get runtime agent thinking config. */ export const getRuntimeAgentThinkingConfigSchema = defineSchema((v) => v.object({ @@ -246,7 +248,7 @@ export function parseRuntimeAgentMarkdownDefinition( ? parseMcpServers(attrs.mcpServers) : undefined; - return getRuntimeAgentMarkdownDefinitionSchema().parse({ + const definition = { id: parsedInput.id, name, description, @@ -262,7 +264,9 @@ export function parseRuntimeAgentMarkdownDefinition( ...(deniedTools === undefined ? {} : { deniedTools }), ...(delegates === undefined ? {} : { delegates }), ...(mcpServers === undefined ? {} : { mcpServers }), - }); + }; + objectSetPrototypeOf(definition, null); + return getRuntimeAgentMarkdownDefinitionSchema().parse(definition); } /** From d4a47f37a5b592c21fcd44c5d35dd8421d309136 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 19:15:31 +0200 Subject: [PATCH 096/194] fix(agent): protect discovery cache and agent lookups --- src/agent/hosted/executor-discovery.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 91ee712bc6..203af95637 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -32,6 +32,11 @@ import { } from "./executor-discovery-schema.ts"; const apply = Reflect.apply; +const MapConstructor = Map; +const mapGet = Map.prototype.get; +const mapSet = Map.prototype.set; +const mapClear = Map.prototype.clear; +const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!; const abortController = AbortController.prototype.abort; const addEventListener = EventTarget.prototype.addEventListener; const removeEventListener = EventTarget.prototype.removeEventListener; @@ -103,7 +108,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut let closing: Promise | undefined; let cleanupStarted = false; const runtimeTasks = createPrivateSet>(); - const definitions = new Map(); + const definitions = new MapConstructor(); const helpers = () => import("#veryfront/agent/project/agent-runtime.ts"); function assertActive() { @@ -125,7 +130,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_CLEANUP_FAILED"); } finally { runtime = undefined; - definitions.clear(); + apply(mapClear, definitions, []); } }); void chain(closing, settled.resolve, settled.reject); @@ -170,13 +175,17 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } async function describeAgent(discovery: ProjectAgentRuntimeDiscovery, agentId: string) { - const cached = definitions.get(agentId); + const cached = apply(mapGet, definitions, [agentId]) as + | RuntimeAgentMarkdownDefinition + | undefined; if (cached) return cached; - if (definitions.size >= EXECUTOR_DISCOVERY_MAX_AGENTS) { + if ((apply(mapSize, definitions, []) as number) >= EXECUTOR_DISCOVERY_MAX_AGENTS) { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_BUSY"); } const module = await observePrivatePromise(helpers()); - const found = discovery.agents.get(agentId); + const found = apply(mapGet, discovery.agents, [agentId]) as ReturnType< + ProjectAgentRuntimeDiscovery["agents"]["get"] + >; let definition: RuntimeAgentMarkdownDefinition; if (found && module.doesProjectAgentRuntimeAgentMatchSource(found, agentSource)) { const projected = await observePrivatePromise(module.runWithProjectAgentRuntime( @@ -220,7 +229,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut if (parsed.id !== agentId) { throw new ExecutorDiscoveryError("EXECUTOR_DISCOVERY_INVALID_OUTPUT"); } - definitions.set(agentId, parsed); + apply(mapSet, definitions, [agentId, parsed]); return parsed; } @@ -275,7 +284,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut } } - const operations = new Map([ + const operations = new MapConstructor([ ["discovery.describe", { mode: "unary", handle(value, context) { From e5b314823e3f56f914c9e0bed0530cafe03fd512 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 19:18:26 +0200 Subject: [PATCH 097/194] fix(agent): protect markdown definition provenance storage --- src/agent/runtime/agent-markdown-adapter.ts | 5 +++-- src/security/private-weak-store.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agent/runtime/agent-markdown-adapter.ts b/src/agent/runtime/agent-markdown-adapter.ts index 3f20120807..f5de185cb1 100644 --- a/src/agent/runtime/agent-markdown-adapter.ts +++ b/src/agent/runtime/agent-markdown-adapter.ts @@ -2,8 +2,9 @@ import { agent } from "../factory.ts"; import type { Agent } from "../types.ts"; import type { RuntimeAgentMarkdownDefinition } from "./agent-definition.ts"; import { AGENT_DELEGATE_TOOL_PREFIX } from "./agent-delegation-names.ts"; +import { createPrivateWeakStore } from "#veryfront/security/private-weak-store.ts"; -const markdownDefinitionByAgent = new WeakMap(); +const markdownDefinitionByAgent = createPrivateWeakStore(); /** Definition for create runtime agent from markdown. */ export function createRuntimeAgentFromMarkdownDefinition( @@ -80,5 +81,5 @@ export function getRuntimeAgentMarkdownDefinition( /** Check whether a runtime agent uses markdown configuration. */ export function isRuntimeAgentMarkdownAgent(runtimeAgent: Agent): boolean { - return markdownDefinitionByAgent.has(runtimeAgent); + return markdownDefinitionByAgent.get(runtimeAgent) !== undefined; } diff --git a/src/security/private-weak-store.ts b/src/security/private-weak-store.ts index 4da4b4b1ea..eee95ab483 100644 --- a/src/security/private-weak-store.ts +++ b/src/security/private-weak-store.ts @@ -4,6 +4,8 @@ type PrivateWeakStore = Readonly<{ }>; const IntrinsicReflectApply = Reflect.apply; +const IntrinsicWeakMap = WeakMap; +const ObjectFreeze = Object.freeze; const WeakMapGet = WeakMap.prototype.get; const WeakMapSet = WeakMap.prototype.set; @@ -12,8 +14,8 @@ export function createPrivateWeakStore(): PrivateWe TKey, TValue > { - const store = new WeakMap(); - return Object.freeze({ + const store = new IntrinsicWeakMap(); + return ObjectFreeze({ get(key: TKey): TValue | undefined { return IntrinsicReflectApply(WeakMapGet, store, [key]) as TValue | undefined; }, From d65d84561b30072c4b969db67ddc752a858cf333 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 19:43:19 +0200 Subject: [PATCH 098/194] fix(agent): observe lifecycle promises without mutating inputs --- src/security/private-promise.test.ts | 42 +++++++++- src/security/private-promise.ts | 110 +++++++++++++++++++-------- 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/src/security/private-promise.test.ts b/src/security/private-promise.test.ts index 1f9717115f..c73b6ff78e 100644 --- a/src/security/private-promise.test.ts +++ b/src/security/private-promise.test.ts @@ -1,8 +1,46 @@ -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { chainPrivatePromise, resolvePrivatePromise } from "./private-promise.ts"; describe("owned promise chains", () => { + it("propagates input and callback failures while allowing explicit recovery", async () => { + const failure = new Error("Synthetic lifecycle failure"); + await assertRejects( + () => chainPrivatePromise(Promise.reject(failure), () => 1), + Error, + "Synthetic lifecycle failure", + ); + await assertRejects( + () => chainPrivatePromise(resolvePrivatePromise(), () => Promise.reject(failure)), + Error, + "Synthetic lifecycle failure", + ); + assertEquals(await chainPrivatePromise(Promise.reject(failure), () => 1, () => 2), 2); + }); + + for (const phase of ["input", "callback"] as const) { + it(`joins a frozen ${phase} promise without changing it`, async () => { + const work = Promise.withResolvers(); + Object.freeze(work.promise); + const result = phase === "input" + ? chainPrivatePromise(work.promise, (value) => value + 1) + : chainPrivatePromise(resolvePrivatePromise(), () => work.promise); + let settled = false; + void result.then(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(settled, false); + } finally { + work.resolve(41); + } + assertEquals(await result, phase === "input" ? 42 : 41); + assertEquals(Object.getOwnPropertyNames(work.promise), []); + assertEquals(Object.isFrozen(work.promise), true); + }); + } + for (const phase of ["input", "callback", "callback with copied constructor"] as const) { it(`joins the original ${phase} promise despite its own constructor and then hooks`, async () => { const work = Promise.withResolvers(); @@ -22,6 +60,7 @@ describe("owned promise chains", () => { configurable: true, }, }); + const originalProperties = Object.getOwnPropertyDescriptors(work.promise); const result = phase === "input" ? chainPrivatePromise(work.promise, (value) => value + 1) : chainPrivatePromise(resolvePrivatePromise(), () => work.promise); @@ -37,6 +76,7 @@ describe("owned promise chains", () => { work.resolve(41); } assertEquals(await result, phase === "input" ? 42 : 41); + assertEquals(Object.getOwnPropertyDescriptors(work.promise), originalProperties); }); } }); diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index 7df939f20b..30afc18d88 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -1,12 +1,11 @@ -import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; - const NativePromise = Promise; +const NativePromisePrototype = Promise.prototype; const apply = Reflect.apply; const promiseThen = Promise.prototype.then; -const promiseResolve = Promise.resolve; const promiseWithResolvers = Promise.withResolvers; const nativeHasInstance = Function.prototype[Symbol.hasInstance]; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getPrototypeOf = Object.getPrototypeOf; const hasOwn = Object.hasOwn; const freeze = Object.freeze; const species: typeof Symbol.species = Symbol.species; @@ -15,9 +14,76 @@ function isNativePromise(value: unknown): value is Promise { return apply(nativeHasInstance, NativePromise, [value]) as boolean; } -function protectResult(value: T): T { - if (isNativePromise(value)) protectPromise(value); - return value; +function hasObservableConstructor(promise: Promise): boolean { + let current: object | null = promise; + for (let depth = 0; current !== null && depth < 128; depth++) { + const descriptor = getOwnPropertyDescriptor(current, "constructor"); + if (descriptor) return current === promise || hasOwn(descriptor, "value"); + if (current === NativePromisePrototype) return false; + current = getPrototypeOf(current); + } + return false; +} + +function observeNative( + promise: Promise, + fulfilled: (value: unknown) => void, + rejected: (reason: unknown) => void, +): void { + try { + // An inherited constructor getter could receive the private promise as its + // receiver. If native observation cannot be installed, keep ownership + // pending for the executor's deadline/fence instead of reporting completion. + if (!hasObservableConstructor(promise)) return; + // Ignore the species-created return value. Only reactions attached to the + // original promise may settle the protected wrapper. + apply(promiseThen, promise, [fulfilled, rejected]); + } catch { + // A hostile constructor/species can prevent observation. It cannot turn + // unfinished work into an observed fulfillment or rejection. + } +} + +function adopt( + value: unknown, + resolve: (value: T | PromiseLike) => void, + reject: (reason: unknown) => void, +): void { + try { + if (isNativePromise(value)) { + observeNative(value, (next) => adopt(next, resolve, reject), reject); + } else { + resolve(value as T); + } + } catch (error) { + reject(error); + } +} + +function createChain( + promise: Promise, + fulfilled?: ((value: T) => U | PromiseLike) | null, + rejected?: ((reason: unknown) => U | PromiseLike) | null, +): Promise { + return new PrivatePromise((resolve, reject) => { + observeNative(promise, (value) => { + try { + adopt(fulfilled ? fulfilled(value as T) : value, resolve, reject); + } catch (error) { + reject(error); + } + }, (reason) => { + if (!rejected) { + reject(reason); + return; + } + try { + adopt(rejected(reason), resolve, reject); + } catch (error) { + reject(error); + } + }); + }); } class PrivatePromise extends NativePromise { @@ -29,52 +95,32 @@ class PrivatePromise extends NativePromise { fulfilled?: ((value: T) => F | PromiseLike) | null, rejected?: ((reason: unknown) => R | PromiseLike) | null, ): Promise { - return apply(promiseThen, this, [ - typeof fulfilled === "function" ? (value: T) => protectResult(fulfilled(value)) : fulfilled, - typeof rejected === "function" - ? (reason: unknown) => protectResult(rejected(reason)) - : rejected, - ]) as Promise; + return createChain(this, fulfilled, rejected); } } freeze(PrivatePromise.prototype); freeze(PrivatePromise); -const privateThen = PrivatePromise.prototype.then; - -function protectPromise(promise: Promise): Promise { - const constructor = getOwnPropertyDescriptor(promise, "constructor"); - if (!constructor || !hasOwn(constructor, "value") || constructor.value !== PrivatePromise) { - defineOwnDataProperty(promise, "constructor", PrivatePromise); - } - const then = getOwnPropertyDescriptor(promise, "then"); - if (!then || !hasOwn(then, "value") || then.value !== privateThen) { - defineOwnDataProperty(promise, "then", privateThen); - } - return promise; -} -/** Observe owned native work through fixed constructor, species, and chaining methods. */ +/** Observe owned native work without changing the input promise. */ export function chainPrivatePromise( promise: Promise, fulfilled: (value: T) => U | PromiseLike, rejected?: (reason: unknown) => U | PromiseLike, ): Promise { - return apply(privateThen, protectPromise(promise), [fulfilled, rejected]) as Promise; + return createChain(promise, fulfilled, rejected); } /** Join a native operation before exposing its result to a lifecycle await. */ export function observePrivatePromise(promise: Promise): Promise { - return chainPrivatePromise(promise, (value) => value); + return createChain(promise, (value) => value); } /** Create the initial settled promise for an owned lifecycle chain. */ export function resolvePrivatePromise(): Promise { - return protectPromise(apply(promiseResolve, NativePromise, []) as Promise); + return new PrivatePromise((resolve) => resolve()); } /** Create an owned completion latch without consulting a replaced constructor helper. */ export function createPrivateDeferred(): PromiseWithResolvers { - const deferred = apply(promiseWithResolvers, NativePromise, []) as PromiseWithResolvers; - protectPromise(deferred.promise); - return deferred; + return apply(promiseWithResolvers, PrivatePromise, []) as PromiseWithResolvers; } From ab6eaf1e69954da0729f0d016956f2fef232077e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 20:01:38 +0200 Subject: [PATCH 099/194] fix(agent): snapshot preparation requests and steering state --- src/agent/hosted/executor-discovery-schema.ts | 44 +------------ .../hosted/executor-runtime-prepare-schema.ts | 44 ++++++++++++- .../hosted/executor-runtime-prepare.test.ts | 65 ++++++++++++++++++- src/agent/hosted/executor-runtime-prepare.ts | 7 +- src/security/own-data-record.ts | 39 +++++++++++ src/security/private-promise.ts | 4 +- 6 files changed, 156 insertions(+), 47 deletions(-) create mode 100644 src/security/own-data-record.ts diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts index 9391088911..a8e77f8454 100644 --- a/src/agent/hosted/executor-discovery-schema.ts +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -1,4 +1,4 @@ -import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { snapshotOwnDataRecords } from "#veryfront/security/own-data-record.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; import { defineError, snapshotVeryfrontError, VeryfrontError } from "#veryfront/errors/types.ts"; @@ -6,44 +6,6 @@ import { getRuntimeAgentMarkdownDefinitionSchema } from "#veryfront/agent/runtim import { executorAgentJson } from "./executor-agent-schema.ts"; import { hasControlCharacters, isWellFormedUtf16 } from "#veryfront/skill/string-safety.ts"; -const objectCreate = Object.create; -const objectKeys = Object.keys; -const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const objectHasOwn = Object.hasOwn; -const arrayIsArray = Array.isArray; - -function snapshotDiscoveryRecords( - value: unknown, - budget = { remaining: 100_000 }, - depth = 0, -): unknown { - if (--budget.remaining < 0 || depth > 64) throw new Error("Invalid discovery data"); - if (value === null || typeof value !== "object") return value; - const array = arrayIsArray(value); - if (array && value.length > 100_000) throw new Error("Invalid discovery data"); - const result = array ? [] : objectCreate(null); - const keys = objectKeys(value); - for (let index = 0; index < keys.length; index++) { - const key = keys[index]!; - const descriptor = objectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !objectHasOwn(descriptor, "value")) { - throw new Error("Invalid discovery accessor"); - } - defineOwnDataProperty( - result, - key, - snapshotDiscoveryRecords(descriptor.value, budget, depth + 1), - { - enumerable: true, - configurable: true, - writable: true, - }, - ); - } - if (array) result.length = value.length; - return result; -} - export const EXECUTOR_DISCOVERY_MAX_AGENTS = 256; export const EXECUTOR_DISCOVERY_MAX_DEFINITION_BYTES = 64 * 1024; const statuses = { @@ -205,8 +167,8 @@ export const getExecutorAgentDescribeResultSchema = defineSchema((v) => export function parseDiscoveryData(schema: Schema, value: unknown, output = false): T { try { - const result = schema.safeParse(snapshotDiscoveryRecords(value)); - if (result.success) return snapshotDiscoveryRecords(result.data) as T; + const result = schema.safeParse(snapshotOwnDataRecords(value)); + if (result.success) return snapshotOwnDataRecords(result.data) as T; } catch { // Snapshot and validation failures share the fixed boundary diagnostic. } diff --git a/src/agent/hosted/executor-runtime-prepare-schema.ts b/src/agent/hosted/executor-runtime-prepare-schema.ts index c5695c9823..8ac4c8be54 100644 --- a/src/agent/hosted/executor-runtime-prepare-schema.ts +++ b/src/agent/hosted/executor-runtime-prepare-schema.ts @@ -1,7 +1,9 @@ +import { snapshotOwnDataRecords } from "#veryfront/security/own-data-record.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; import { defineError, VeryfrontError } from "#veryfront/errors/types.ts"; import { getExecutorDiscoveryIdSchema } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { getRuntimeAgentMarkdownDefinitionSchema } from "#veryfront/agent/runtime/agent-definition.ts"; const failureStatus = { EXECUTOR_RUNTIME_INVALID_INPUT: 400, @@ -58,6 +60,38 @@ export type ExecutorRuntimePrepareRequest = InferSchema< ReturnType >; +export const getExecutorRuntimeSteeringSchema = defineSchema((v) => + v.object({ + agent: getRuntimeAgentMarkdownDefinitionSchema(), + environmentContext: v.string().optional(), + initialProjectInstructions: v.string().optional(), + skillSelectorPolicy: v.union([ + v.object({ kind: v.literal("all-visible"), source: v.enum(["omitted", "true"] as const) }) + .strict(), + v.object({ kind: v.literal("none") }).strict(), + v.object({ kind: v.literal("allowlist"), entries: v.array(v.string()) }).strict(), + ]).optional(), + initialSkills: v.array( + v.object({ + id: v.string().min(1), + name: v.string(), + description: v.string(), + instructions: v.string(), + displayName: v.string().optional(), + allowedTools: v.array(v.string()).optional(), + metadata: v.record(v.string(), v.string()).optional(), + model: v.string().optional(), + thinking: v.union([v.literal(false), v.number()]).optional(), + maxSteps: v.number().optional(), + references: v.array(v.string()).optional(), + ownerAgentId: v.string().optional(), + shortName: v.string().optional(), + sourcePath: v.string().optional(), + }).strict(), + ).optional(), + }).strict() +); + export const getExecutorRuntimeGrantDataSchema = defineSchema((v) => { const context = { projectId: v.string().nullable(), @@ -99,7 +133,11 @@ export type ExecutorRuntimeGrantData = InferSchema< >; export function parseRuntimePreparationData(schema: Schema, value: unknown): T { - const result = schema.safeParse(value); - if (!result.success) throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_INVALID_INPUT"); - return result.data; + try { + const result = schema.safeParse(snapshotOwnDataRecords(value)); + if (result.success) return snapshotOwnDataRecords(result.data) as T; + } catch { + // Keep invalid data and accessor failures behind the fixed input diagnostic. + } + throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_INVALID_INPUT"); } diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 6790d90c40..17d65feb9a 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -19,7 +19,11 @@ import { type ExecutorRuntimeFacades, type ExecutorRuntimePreparationGrant, } from "./executor-runtime-prepare.ts"; -import { ExecutorRuntimePreparationError } from "./executor-runtime-prepare-schema.ts"; +import { + ExecutorRuntimePreparationError, + getExecutorRuntimePrepareRequestSchema, + parseRuntimePreparationData, +} from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; import { createExecutorHostedChatRuntimeAgent } from "./executor-agent-bridge.ts"; @@ -592,6 +596,65 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + it("validates preparation requests without inheriting optional model limits", () => { + let reads = 0; + const request = Object.create({ + get modelId() { + reads++; + return "veryfront-cloud/openai/other"; + }, + get maxOutputTokens() { + reads++; + return 999; + }, + }, { agentId: { value: "coder", enumerable: true } }); + const parsed = parseRuntimePreparationData(getExecutorRuntimePrepareRequestSchema(), request); + assertEquals(reads, 0); + assertEquals(parsed.modelId, undefined); + assertEquals(parsed.maxOutputTokens, undefined); + }); + + it("does not treat inherited steering skills as authorized", async () => { + let reads = 0; + let visible: string[] = []; + const f = fixture({ + config: { tools: {}, skills: true }, + grant: { ...grant, allowedToolNames: ["load_skill"], hostToolFacadeIds: ["skills"] }, + facades: { + hostTools: new Map([["skills", { load_skill: syntheticHostTool() }]]), + projectSteering: { + prepare: ({ definition }) => + Promise.resolve(Object.create({ + get initialSkills() { + reads++; + return [{ + id: "injected", + name: "Injected", + description: "Synthetic", + instructions: "Synthetic", + }]; + }, + }, { agent: { value: definition, enumerable: true } })), + refresh: () => "Synthetic instructions", + }, + resolveModelRuntime: () => ({ + ...model, + doStream(options) { + visible = (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? []; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(reads, 0); + assertEquals(visible, []); + } finally { + await f.owner.close(); + } + }); + it("ignores inherited initial checkpoint state during canonical preparation", async () => { let inheritedReads = 0; type CheckpointFacade = NonNullable; diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 7278d388f8..375f5cb0a8 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -74,6 +74,7 @@ import { type ExecutorRuntimePrepareRequest, getExecutorRuntimeGrantDataSchema, getExecutorRuntimePrepareRequestSchema, + getExecutorRuntimeSteeringSchema, parseRuntimePreparationData, } from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; @@ -483,7 +484,7 @@ export function createExecutorRuntimePreparation(input: Options) { resourcesStarted = true; resolveModelRuntime(modelId); const execution = grant.execution; - const steering = facades.projectSteering + const steeringResult = facades.projectSteering ? await observePrivatePromise(facades.projectSteering.prepare({ definition, projectId: execution.projectId, @@ -491,6 +492,10 @@ export function createExecutorRuntimePreparation(input: Options) { 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({ diff --git a/src/security/own-data-record.ts b/src/security/own-data-record.ts new file mode 100644 index 0000000000..52c9ddec93 --- /dev/null +++ b/src/security/own-data-record.ts @@ -0,0 +1,39 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const objectCreate = Object.create; +const objectKeys = Object.keys; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectHasOwn = Object.hasOwn; +const arrayIsArray = Array.isArray; + +export function snapshotOwnDataRecords( + value: unknown, + budget = { remaining: 100_000 }, + depth = 0, +): unknown { + if (--budget.remaining < 0 || depth > 64) throw new Error("Invalid own-data record"); + if (value === null || typeof value !== "object") return value; + const array = arrayIsArray(value); + if (array && value.length > 100_000) throw new Error("Invalid own-data record"); + const result = array ? [] : objectCreate(null); + const keys = objectKeys(value); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const descriptor = objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !objectHasOwn(descriptor, "value")) { + throw new Error("Invalid data accessor"); + } + defineOwnDataProperty( + result, + key, + snapshotOwnDataRecords(descriptor.value, budget, depth + 1), + { + enumerable: true, + configurable: true, + writable: true, + }, + ); + } + if (array) result.length = value.length; + return result; +} diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index 30afc18d88..d6c0ca4f82 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -91,7 +91,9 @@ class PrivatePromise extends NativePromise { return PrivatePromise; } - override then( + // This native Promise subclass intentionally implements the Promise protocol. + // Its override routes await/then through protected reactions rather than mutable hooks. + override then( // NOSONAR S7739: intentional Promise override, not an accidental thenable. fulfilled?: ((value: T) => F | PromiseLike) | null, rejected?: ((reason: unknown) => R | PromiseLike) | null, ): Promise { From cb876e971cab879fa2b4e946b3ed67c6de782737 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 20:06:08 +0200 Subject: [PATCH 100/194] fix(agent): bind durable queue work to executor admission --- docs/api-reference/veryfront/agent.md | 1 - docs/guides/agent-service-runtime.md | 6 + .../conversation/run-chunk-mirror.test.ts | 36 ++++ src/agent/conversation/run-chunk-mirror.ts | 5 + src/agent/conversation/run-mirror.test.ts | 94 +++++++++ src/agent/conversation/run-mirror.ts | 12 +- src/agent/hosted/executor-session.ts | 3 + .../hosted/managed-broker-persistence.ts | 50 +++-- .../hosted/managed-executor-broker.test.ts | 178 +++++++++++++++++- src/agent/hosted/managed-executor-broker.ts | 14 ++ src/agent/service/managed-broker-handler.ts | 9 +- .../agent/managed-broker-persistence.test.ts | 97 +++++++++- 12 files changed, 465 insertions(+), 40 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 6f94aec1d7..25f683b404 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -2027,7 +2027,6 @@ import { | `ManagedAgentBrokerIngressAuthority` | Private parsed request and credentials available only to trusted broker preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | | `ManagedAgentExecutorRequest` | Detached bounded application data, without HTTP objects or broker credentials. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | | `ManagedAgentIngressResult` | Preserve the distinct durable and direct AG-UI ingress contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedBrokerOutput` | Broker-owned output persistence for a detached run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | | `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | | `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | | `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 123879cbda..49c7fe127d 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -448,6 +448,12 @@ model and tool execution unavailable during preparation. Configure detached Detached runs require output persistence callbacks; their finalization remains part of the session's owned work until all writes settle. +Canonical runs must pass the persistence adapter's `bindSessionOwnedWork` +callback in `ManagedExecutorStartInput`. The broker binds it after reserving a +session and before installation or preparation. Scheduled, retry, and explicit +event-queue writes use that session owner. A persistence timeout can return +promptly while pool capacity stays reserved until the original write settles. + `startNodeManagedAgentBroker` binds the signed stream, durable start, AG-UI, and cancel/resume handlers to a Node server. Supply every handler, the broker pool, and a readiness check explicitly. It preserves `/liveness` and diff --git a/src/agent/conversation/run-chunk-mirror.test.ts b/src/agent/conversation/run-chunk-mirror.test.ts index 4d24714f15..45a1168945 100644 --- a/src/agent/conversation/run-chunk-mirror.test.ts +++ b/src/agent/conversation/run-chunk-mirror.test.ts @@ -434,6 +434,42 @@ describe("agent/conversation-run-chunk-mirror", () => { } }); + it("forwards hosted queue flush ownership before an automatic append request", async () => { + const calls: string[] = []; + const mirror = createHostedConversationRunChunkMirror({ + authToken: "token", + apiUrl: "https://api.example.test", + conversationId: "11111111-1111-4111-8111-111111111111", + runId: "run-1", + latestEventId: 0, + batchSize: 1, + runQueueFlush: async (operation) => { + calls.push("owner"); + return await operation(); + }, + fetch: () => { + calls.push("fetch"); + return Promise.resolve(Response.json({ + latest_event_id: 1, + latest_external_event_sequence: 1, + appended_count: 1, + run: { + run_id: "run-1", + conversation_id: "11111111-1111-4111-8111-111111111111", + latest_event_id: 1, + latest_external_event_sequence: 1, + }, + })); + }, + }); + + await mirror.appendEvents([{ type: "TEXT_MESSAGE_CONTENT", delta: "owned" }]); + await mirror.flush(); + + assertEquals(calls, ["owner", "fetch"]); + mirror.dispose(); + }); + // VERYFRONT-AGENT-3: every retry_scheduled flush logged at error level, so a // degraded append endpoint emitted a Sentry error per ~5s retry per run. The // per-attempt log must stay at warn and escalate to error only once the diff --git a/src/agent/conversation/run-chunk-mirror.ts b/src/agent/conversation/run-chunk-mirror.ts index 29a40ad68f..14120c2718 100644 --- a/src/agent/conversation/run-chunk-mirror.ts +++ b/src/agent/conversation/run-chunk-mirror.ts @@ -10,6 +10,7 @@ import { type ConversationRunMirrorRetryScheduledState, type ConversationRunMirrorSnapshot, type ConversationRunMirrorStoppedState, + type ConversationRunQueueFlush, createConversationRunMirror, } from "./run-mirror.ts"; import { @@ -71,6 +72,7 @@ interface ConversationRunChunkMirrorSharedOptions { onHighBacklog?: (state: ConversationRunMirrorHighBacklogState) => Promise | void; onRetryScheduled?: (state: ConversationRunMirrorRetryScheduledState) => Promise | void; onStopped?: (state: ConversationRunMirrorStoppedState) => Promise | void; + runQueueFlush?: ConversationRunQueueFlush; prepareChunkEvents?: ( input: ConversationRunChunkMirrorPrepareChunkEventsInput, ) => Promise | ConversationRunEvent[]; @@ -135,6 +137,7 @@ export interface HostedConversationRunChunkMirrorOptions { batchSize?: number; highBacklogEventCount?: number; instrumentation?: HostedConversationRunChunkMirrorInstrumentation; + runQueueFlush?: ConversationRunQueueFlush; /** Explicit host-owned transport for trusted runtime composition and tests. */ fetch?: typeof globalThis.fetch; } @@ -188,6 +191,7 @@ export function createConversationRunChunkMirror( ...(input.onHighBacklog ? { onHighBacklog: input.onHighBacklog } : {}), ...(input.onRetryScheduled ? { onRetryScheduled: input.onRetryScheduled } : {}), ...(input.onStopped ? { onStopped: input.onStopped } : {}), + ...(input.runQueueFlush ? { runQueueFlush: input.runQueueFlush } : {}), }); return { @@ -418,6 +422,7 @@ export function createHostedConversationRunChunkMirror( immediateFlushEventCount: batchSize, highBacklogEventCount, fetch: input.fetch, + ...(input.runQueueFlush ? { runQueueFlush: input.runQueueFlush } : {}), prepareChunkEvents: ({ chunk, defaultPrepare }) => runHostedChunkMirrorTrace(input.instrumentation, "durable.mirrorChunk", async () => { const events = defaultPrepare(); diff --git a/src/agent/conversation/run-mirror.test.ts b/src/agent/conversation/run-mirror.test.ts index a6a6d60253..2323c89c19 100644 --- a/src/agent/conversation/run-mirror.test.ts +++ b/src/agent/conversation/run-mirror.test.ts @@ -200,6 +200,100 @@ describe("agent/conversation-run-mirror", () => { assertEquals(mirror.getSnapshot().pendingEventCount, 0); }); + it("runs immediate, retry, and explicit queue flushes inside the owner hook", async () => { + using time = new FakeTime(); + const calls: string[] = []; + let flushCalls = 0; + const controller = createMockQueueController({ + flushImpl: async () => { + calls.push("controller"); + flushCalls += 1; + if (flushCalls === 1) { + return { + outcome: "retry_scheduled" as const, + latestEventId: 0, + latestExternalEventSequence: 0, + pendingEventCount: 1, + consecutiveFailures: 1, + disabled: false, + errorMessage: "retry", + }; + } + return { + outcome: "flushed" as const, + latestEventId: flushCalls, + latestExternalEventSequence: flushCalls, + pendingEventCount: 0, + consecutiveFailures: 0, + disabled: false, + }; + }, + }); + const mirror = createConversationRunMirror({ + queueController: controller, + immediateFlushEventCount: 1, + getRetryDelayMs: () => 25, + runQueueFlush: async (operation) => { + calls.push("owner"); + return await operation(); + }, + }); + + mirror.enqueue([{ id: "immediate" }]); + await time.tickAsync(0); + await time.tickAsync(25); + mirror.enqueue([{ id: "explicit" }]); + await mirror.flush(); + + assertEquals(calls, [ + "owner", + "controller", + "owner", + "controller", + "owner", + "controller", + ]); + mirror.dispose(); + }); + + it("does not enter a delayed queue flush after its owner closes", async () => { + using time = new FakeTime(); + let active = true; + let ownerCalls = 0; + let controllerCalls = 0; + const controller = createMockQueueController({ + flushImpl: async () => { + controllerCalls += 1; + return { + outcome: "flushed" as const, + latestEventId: 1, + latestExternalEventSequence: 1, + pendingEventCount: 0, + consecutiveFailures: 0, + disabled: false, + }; + }, + }); + const mirror = createConversationRunMirror({ + queueController: controller, + immediateFlushEventCount: 2, + flushDelayMs: 50, + runQueueFlush: async (operation) => { + ownerCalls += 1; + if (!active) throw new Error("owner closed"); + return await operation(); + }, + }); + + mirror.enqueue([{ id: "delayed" }]); + active = false; + await time.tickAsync(50); + + assertEquals(ownerCalls, 1); + assertEquals(controllerCalls, 0); + mirror.dispose(); + }); + it("rejects an explicit flush when a controller failure escaped the queue", async () => { const controller = createMockQueueController({ pendingEvents: [], diff --git a/src/agent/conversation/run-mirror.ts b/src/agent/conversation/run-mirror.ts index d6c46c8546..41a4301981 100644 --- a/src/agent/conversation/run-mirror.ts +++ b/src/agent/conversation/run-mirror.ts @@ -57,6 +57,9 @@ export interface ConversationRunMirrorHighBacklogState { threshold: number; } +/** Own one underlying queue flush for its complete asynchronous lifetime. */ +export type ConversationRunQueueFlush = (operation: () => Promise) => Promise; + /** Public API contract for conversation run mirror. */ export interface ConversationRunMirror { enqueue(events: unknown[]): void; @@ -101,6 +104,7 @@ export function createConversationRunMirror(input: { onHighBacklog?: (state: ConversationRunMirrorHighBacklogState) => Promise | void; onRetryScheduled?: (state: ConversationRunMirrorRetryScheduledState) => Promise | void; onStopped?: (state: ConversationRunMirrorStoppedState) => Promise | void; + runQueueFlush?: ConversationRunQueueFlush; }): ConversationRunMirror { const flushDelayMs = input.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS; const getRetryDelayMs = input.getRetryDelayMs ?? getDefaultRetryDelayMs; @@ -209,9 +213,11 @@ export function createConversationRunMirror(input: { async function runFlushLoop(abortSignal?: AbortSignal): Promise { emitHighBacklogIfNeeded(); - const flushed = await input.queueController.flush({ - abortSignal: abortSignal ?? lifecycleAbortController.signal, - }); + const flush = () => + input.queueController.flush({ + abortSignal: abortSignal ?? lifecycleAbortController.signal, + }); + const flushed = await (input.runQueueFlush ? input.runQueueFlush(flush) : flush()); escapedFlushFailures = 0; escapedFlushError = null; diff --git a/src/agent/hosted/executor-session.ts b/src/agent/hosted/executor-session.ts index 086b7b78dc..471234ee54 100644 --- a/src/agent/hosted/executor-session.ts +++ b/src/agent/hosted/executor-session.ts @@ -82,6 +82,9 @@ export interface HostedExecutorSessionCloseResult { release: "not-allocated" | "released" | "reaper-required"; } +/** Retain broker-local asynchronous work through complete session settlement. */ +export type HostedExecutorOwnedWork = (operation: () => Promise) => Promise; + export interface HostedExecutorSession { /** TLS and invocation-channel readiness, before remote runtime preparation/acceptance. */ readonly ready: Promise; diff --git a/src/agent/hosted/managed-broker-persistence.ts b/src/agent/hosted/managed-broker-persistence.ts index e3e7b4a48f..75fd4298bd 100644 --- a/src/agent/hosted/managed-broker-persistence.ts +++ b/src/agent/hosted/managed-broker-persistence.ts @@ -23,7 +23,7 @@ import { } from "../runtime/provider-replay.ts"; import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; import type { HostedLifecycleTerminalState } from "./lifecycle.ts"; -import type { ConversationRunChunkMirror } from "../conversation/run-chunk-mirror.ts"; +import type { HostedExecutorOwnedWork } from "./executor-session.ts"; /** Acknowledging output writes and terminal finalization for a canonical run. */ export interface ManagedBrokerOutput { @@ -48,6 +48,19 @@ export function createManagedBrokerPersistence(input: { if (run.status !== "pending" && run.status !== "running" && run.status !== "waiting_for_tool") { throw new TypeError("Managed broker persistence requires an active run"); } + let sessionOwnedWork: HostedExecutorOwnedWork | undefined; + let retainedPersistenceTail = Promise.resolve(); + let cleaned = false; + const runQueueFlush = (operation: () => Promise): Promise => { + const owner = sessionOwnedWork; + if (!owner) { + return Promise.reject(new TypeError("Managed broker persistence is not session-bound")); + } + const owned = owner(operation); + const settled = owned.then(() => undefined, () => undefined); + retainedPersistenceTail = Promise.all([retainedPersistenceTail, settled]).then(() => undefined); + return owned; + }; const capability = createHostedRunEventWriterCapability({ apiUrl: input.apiUrl, runId: run.runId, @@ -59,6 +72,7 @@ export function createManagedBrokerPersistence(input: { conversationId: run.conversationId, latestEventId: run.latestEventId, latestExternalEventSequence: run.latestExternalEventSequence, + runQueueFlush, }); if (!mirror) throw new TypeError("Managed broker run-event capability is not bound"); const durableMirror = mirror; @@ -70,32 +84,20 @@ export function createManagedBrokerPersistence(input: { resolveProvider: input.resolveProvider, fetch: input.fetch, }); - let retainedPersistenceTail = Promise.resolve(); - const retainOriginalPersistence = (operation: Promise): Promise => { - const settled = operation.then(() => undefined, () => undefined); - retainedPersistenceTail = Promise.all([retainedPersistenceTail, settled]).then(() => undefined); - return operation; - }; - const durableSinkMirror: ConversationRunChunkMirror = { - ...(durableMirror.timing ? { timing: durableMirror.timing } : {}), - handleChunk: (chunk) => durableMirror.handleChunk(chunk), - appendEvents: (events) => durableMirror.appendEvents(events), - flush: (options) => retainOriginalPersistence(durableMirror.flush(options)), - getSnapshot: () => durableMirror.getSnapshot(), - dispose: () => durableMirror.dispose(), - }; - const durableSink = createDurableRunEventSink({ mirror: durableSinkMirror }); + const durableSink = createDurableRunEventSink({ mirror: durableMirror }); let tail = Promise.resolve(); let failure: unknown; let failed = false; let finished = false; - let cleaned = false; const queue = ( operation: (priorFailure: { failed: boolean; error: unknown }) => Promise, terminal = false, ): Promise => { if (cleaned) return Promise.reject(new TypeError("Managed broker persistence is closed")); + if (!sessionOwnedWork) { + return Promise.reject(new TypeError("Managed broker persistence is not session-bound")); + } if (finished && !terminal) { return Promise.reject(new TypeError("Managed broker persistence is finished")); } @@ -139,6 +141,9 @@ export function createManagedBrokerPersistence(input: { }, finish(result) { if (finished) return Promise.reject(new TypeError("Managed broker output is finished")); + if (!sessionOwnedWork) { + return Promise.reject(new TypeError("Managed broker persistence is not session-bound")); + } if (result.completed && result.error !== undefined) { return Promise.reject(new TypeError("Completed managed output cannot carry an error")); } @@ -193,7 +198,18 @@ export function createManagedBrokerPersistence(input: { await retainedPersistenceTail; durableMirror.dispose(); } + function bindSessionOwnedWork(owner: HostedExecutorOwnedWork): void { + if (typeof owner !== "function") { + throw new TypeError("Managed broker persistence owner must be a function"); + } + if (cleaned) throw new TypeError("Managed broker persistence is closed"); + if (sessionOwnedWork) { + throw new TypeError("Managed broker persistence is already session-bound"); + } + sessionOwnedWork = owner; + } return { + bindSessionOwnedWork, modelRunEventSink, publishParentRunEvents: persistEvents, persistToolExposureCheckpoint: (checkpoint: ToolExposureCheckpoint) => diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index 397d9c0bb0..3ef90695e1 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -14,6 +14,8 @@ import { import { getExecutorRuntimePrepareResultSchema } from "./executor-runtime-prepare-schema.ts"; import { readExecutorInitialCheckpoints } from "./executor-checkpoint-state.ts"; import { executorStateOperations } from "./executor-state-schema.ts"; +import { createManagedBrokerPersistence } from "./managed-broker-persistence.ts"; +import { FakeTime } from "#std/testing/time"; const modelId = "veryfront-cloud/openai/synthetic"; const owner = { scopeKind: "project" as const, projectId: "project-test" }; @@ -44,6 +46,8 @@ function fixture( prepareModelId?: string; brokerReadWait?: boolean; initialCheckpoint?: boolean; + allocationLifetimeMs?: number; + hardDeadlineMs?: number; } = {}, ) { const now = Date.now(); @@ -54,7 +58,7 @@ function fixture( source, requestedAt: now, prepareDeadlineAt: now + 10_000, - hardDeadlineAt: now + 60_000, + hardDeadlineAt: now + (options.hardDeadlineMs ?? 60_000), }; const calls: string[] = []; let peer: ReturnType | undefined; @@ -98,7 +102,7 @@ function fixture( brokerInstanceId: "broker-test", }, phase, - expiresAt: now + 30_000, + expiresAt: now + (options.allocationLifetimeMs ?? 30_000), ...(reason ? { reason } : {}), ...(phase === "ready" ? { @@ -287,6 +291,31 @@ function fixture( }; } +function configureCanonical( + input: ManagedExecutorStartInput, + runEventSink: NonNullable, + bindSessionOwnedWork?: ManagedExecutorStartInput["bindSessionOwnedWork"], +): void { + input.installation.grant.execution = { + kind: "canonical", + projectId: null, + conversationId: "conversation-1", + runId: "run-1", + messageId: "message-1", + providerReplay: "disabled", + }; + input.installation.capabilities.persistence = { + publishParentRunEvents: "parent-events", + toolExposureCheckpoint: "tool-checkpoint", + }; + input.persistence = { + publishParentRunEvents: () => Promise.resolve(), + persistToolExposureCheckpoint: () => Promise.resolve(), + }; + input.model.runEventSink = runEventSink; + if (bindSessionOwnedWork) input.bindSessionOwnedWork = bindSessionOwnedWork; +} + describe("managed executor broker", () => { it("installs, discovers, prepares, accepts, and begins execution in exact order", async () => { const f = fixture({ initialCheckpoint: true }); @@ -352,6 +381,32 @@ describe("managed executor broker", () => { await ephemeralBroker.shutdown(); }); + it("requires canonical session-work binding before allocation", async () => { + const f = fixture(); + configureCanonical(f.input, () => Promise.resolve()); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + + await assertRejects(() => broker.start(f.input)); + assertEquals(f.calls, []); + assertEquals(broker.active, 0); + await broker.shutdown(); + }); + + it("binds canonical persistence ownership immediately after pool admission", async () => { + const f = fixture(); + configureCanonical(f.input, () => Promise.resolve(), (owner) => { + f.calls.push("bind-owned-work"); + assertEquals(typeof owner, "function"); + }); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + const runtime = await broker.start(f.input); + + assertEquals(f.calls.slice(0, 3), ["allocate", "bind-owned-work", "connect"]); + await runtime.close(); + await runtime.settled; + await broker.shutdown(); + }); + it("closes and releases a session when remote preparation fails", async () => { const f = fixture({ prepareFailure: true }); const broker = createManagedExecutorBroker({ maxActive: 1 }); @@ -435,6 +490,7 @@ describe("managed executor broker", () => { entered.resolve(); await release.promise; }; + f.input.bindSessionOwnedWork = () => {}; const broker = createManagedExecutorBroker({ maxActive: 1 }); const runtime = await broker.start(f.input); runtime.accept({ kind: "execution" }); @@ -458,6 +514,124 @@ describe("managed executor broker", () => { await broker.shutdown(); }); + it("retains max-active admission for the original canonical append after its deadline", async () => { + using time = new FakeTime(); + const f = fixture({ allocationLifetimeMs: 90_000, hardDeadlineMs: 120_000 }); + const conversationId = "00000000-0000-4000-8000-000000000001"; + const messageId = "00000000-0000-4000-8000-000000000002"; + const appendEntered = Promise.withResolvers(); + const appendRelease = Promise.withResolvers(); + const terminalCalls: Record[] = []; + let delayAuditAppend = true; + const fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + if (delayAuditAppend && Array.isArray(body.events)) { + delayAuditAppend = false; + appendEntered.resolve(); + return await appendRelease.promise; + } + terminalCalls.push(body); + return Response.json({ + completed: true, + run: { runId: "run-1", status: body.status }, + }); + }; + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run: { + runId: "run-1", + conversationId, + messageId, + latestEventId: 0, + latestExternalEventSequence: 0, + waitingToolCallId: null, + waitingToolName: null, + status: "running", + streamProtocolVersion: 2, + }, + modelId, + resolveProvider: () => "provider", + fetch, + }); + configureCanonical( + f.input, + persistence.modelRunEventSink, + persistence.bindSessionOwnedWork, + ); + f.input.installation.grant.execution = { + kind: "canonical", + projectId: null, + conversationId, + runId: "run-1", + messageId, + providerReplay: "disabled", + }; + f.input.persistence = { + publishParentRunEvents: persistence.publishParentRunEvents, + persistToolExposureCheckpoint: persistence.persistToolExposureCheckpoint, + }; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + const runtime = await broker.start(f.input); + runtime.accept({ kind: "execution" }); + const opening = runtime.agent.stream({ + messages: [], + abortSignal: new AbortController().signal, + }); + await appendEntered.promise; + + await time.tickAsync(30_000); + const openingResult = await Promise.allSettled([opening]); + const streamError = openingResult[0]?.status === "rejected" + ? openingResult[0].reason + : undefined; + assert(streamError instanceof Error); + + const finishResult = await Promise.allSettled([ + runtime.runOwned(() => persistence.output.finish({ completed: false, error: streamError })), + ]); + assertEquals(finishResult[0]?.status, "rejected"); + assertEquals( + finishResult[0]?.status === "rejected" && finishResult[0].reason instanceof Error + ? finishResult[0].reason.message + : undefined, + "Durable run event persistence timed out", + ); + assertEquals(terminalCalls.length, 1); + assertEquals(terminalCalls.at(-1)?.status, "failed"); + + const closing = runtime.close(); + await time.tickAsync(50); + await closing; + let settled = false; + void runtime.settled.then(() => settled = true); + for (let index = 0; index < 20; index += 1) await Promise.resolve(); + assertEquals(settled, false); + assertEquals(broker.active, 1); + + const second = fixture(); + await assertRejects(() => broker.start(second.input)); + appendRelease.resolve(Response.json({ + latest_event_id: 1, + latest_external_event_sequence: 1, + appended_count: 1, + run: { + run_id: "run-1", + conversation_id: conversationId, + latest_event_id: 1, + latest_external_event_sequence: 1, + }, + })); + await runtime.settled; + await persistence.cleanup(); + assertEquals(broker.active, 0); + + const nextRuntime = await broker.start(second.input); + await nextRuntime.close(); + await nextRuntime.settled; + await broker.shutdown(); + }); + it("exports a strict preparation result schema", () => { assertEquals( getExecutorRuntimePrepareResultSchema().safeParse({ diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index 2a34c8945c..0182479c74 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -12,6 +12,7 @@ import { type HostedExecutorSessionPoolOptions, } from "./executor-session-pool.ts"; import type { + HostedExecutorOwnedWork, HostedExecutorSessionCloseResult, HostedExecutorSessionOptions, } from "./executor-session.ts"; @@ -76,6 +77,8 @@ export interface ManagedExecutorRuntime { /** Trusted per-invocation source, model, tool, persistence, and state authority. */ export interface ManagedExecutorStartInput { + /** Bind canonical persistence to the admitted session before readiness work starts. */ + bindSessionOwnedWork?: (owner: HostedExecutorOwnedWork) => void; session: SessionInput; installation: InstallInput; prepare: ExecutorRuntimePrepareRequest; @@ -135,12 +138,22 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption ) throw new TypeError("Managed executor installation does not match its session"); const allowedModelIds = new Set(installation.grant.models.map((model) => model.id)); const operationInput = snapshotOperationInput(input); + const bindSessionOwnedWork = input.bindSessionOwnedWork; if ( installation.grant.execution.kind === "ephemeral" && operationInput.model.runEventSink !== undefined ) { throw new TypeError("Ephemeral executor model dispatch cannot receive a run event sink"); } + if ( + installation.grant.execution.kind === "canonical" && + typeof bindSessionOwnedWork !== "function" + ) { + throw new TypeError("Canonical executor persistence requires session-owned work binding"); + } + if (bindSessionOwnedWork !== undefined && typeof bindSessionOwnedWork !== "function") { + throw new TypeError("Invalid executor session-owned work binder"); + } // Validate every trusted capability before reserving pool admission. buildBrokerOperations( validationBinding, @@ -175,6 +188,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption }, }); try { + bindSessionOwnedWork?.(session.runOwned.bind(session)); lifecycle.onAdmitted?.(session.settled); const channel = await session.ready; const binding = session.binding; diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index f7f44977ed..3348afba76 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -1,5 +1,4 @@ import type { HostedChatRuntimeStreamInput } from "../hosted/chat-runtime-contract.ts"; -import type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; import { ExecutorAgentError, getExecutorAgentFailureCodeSchema, @@ -15,6 +14,7 @@ import type { ManagedExecutorRuntime, ManagedExecutorStartInput, } from "../hosted/managed-executor-broker.ts"; +import type { ManagedBrokerOutput } from "../hosted/managed-broker-persistence.ts"; import { BrokerIngressError, type BrokerRuntimeAgentIngress, @@ -32,13 +32,6 @@ export interface ManagedExecutorStarter { ): Promise; } -/** Broker-owned output persistence for a detached run. */ -export interface ManagedBrokerOutput { - write(chunk: ChatUiMessageChunk): Promise; - /** Acknowledge all original queued writes, including cancellation/failure finalization. */ - finish(outcome: { completed: boolean; error?: unknown }): Promise; -} - /** Handle signed run invocations with configured detached or request-owned SSE responses. */ export function createManagedBrokerHandler(options: { broker: ManagedExecutorStarter; diff --git a/tests/integration/agent/managed-broker-persistence.test.ts b/tests/integration/agent/managed-broker-persistence.test.ts index d158c9ef39..d35a5156fd 100644 --- a/tests/integration/agent/managed-broker-persistence.test.ts +++ b/tests/integration/agent/managed-broker-persistence.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { createManagedBrokerPersistence } from "#veryfront/agent/hosted/managed-broker-persistence.ts"; @@ -42,21 +42,88 @@ function successfulFetch(calls: Record[]) { }; } +function bindForTest( + persistence: ReturnType, +): ReturnType { + persistence.bindSessionOwnedWork(async (operation) => await operation()); + return persistence; +} + describe("managed broker persistence", () => { + it("requires one active session owner before persistence can enqueue or fetch", async () => { + const calls: Record[] = []; + const persistence = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "model", + resolveProvider: () => "provider", + fetch: successfulFetch(calls), + }); + + await assertRejects(async () => + await persistence.modelRunEventSink({ + type: "AGENT_RUN_MODEL_CALL_CONTEXT", + messages: [], + tools: [], + }) + ); + await assertRejects(() => persistence.publishParentRunEvents([{ type: "STEP_STARTED" }])); + await assertRejects(() => + persistence.persistToolExposureCheckpoint({ + version: 2, + loadedToolNames: ["search"], + }) + ); + await assertRejects(() => + persistence.persistProviderReplayCheckpoint({ + version: 1, + messageId, + provider: "anthropic", + providerBlocks: [], + providerBlockPositions: [], + providerMessageBlockCounts: [], + totalPartCount: 0, + }) + ); + await assertRejects(() => + persistence.output.write({ type: "text-delta", id: "message", delta: "blocked" }) + ); + await assertRejects(() => persistence.output.finish({ completed: false })); + assertEquals(calls, []); + + const owner = async (operation: () => Promise): Promise => await operation(); + assertThrows(() => persistence.bindSessionOwnedWork(undefined as never)); + persistence.bindSessionOwnedWork(owner); + assertThrows(() => persistence.bindSessionOwnedWork(owner)); + await persistence.cleanup(); + + const cleaned = createManagedBrokerPersistence({ + apiUrl: "https://api.example.test", + runEventToken: "run-event-token", + run, + modelId: "model", + resolveProvider: () => "provider", + fetch: successfulFetch([]), + }); + await cleaned.cleanup(); + assertThrows(() => cleaned.bindSessionOwnedWork(owner)); + }); + it("persists output, audit, parent events, checkpoints, and terminal completion", async () => { const calls: Record[] = []; const fetch = successfulFetch(calls); await withMockFetch( () => Promise.reject(new Error("external fetch must not be used")), async () => { - const persistence = createManagedBrokerPersistence({ + const persistence = bindForTest(createManagedBrokerPersistence({ apiUrl: "https://api.example.test", runEventToken: "run-event-token", run, modelId: "veryfront-cloud/openai/synthetic", resolveProvider: () => "openai", fetch, - }); + })); await persistence.output.write({ type: "text-delta", id: "message", delta: "hello" }); await persistence.modelRunEventSink({ type: "AGENT_RUN_MODEL_CALL_CONTEXT", @@ -128,14 +195,14 @@ describe("managed broker persistence", () => { return await fallback(input, init); }; await withMockFetch(fetch, async () => { - const persistence = createManagedBrokerPersistence({ + const persistence = bindForTest(createManagedBrokerPersistence({ apiUrl: "https://api.example.test", runEventToken: "run-event-token", run, modelId: "model", resolveProvider: () => "provider", fetch, - }); + })); const write = persistence.output.write({ type: "text-delta", id: "message", @@ -178,14 +245,14 @@ describe("managed broker persistence", () => { return await fallback(input, init); }; await withMockFetch(fetch, async () => { - const persistence = createManagedBrokerPersistence({ + const persistence = bindForTest(createManagedBrokerPersistence({ apiUrl: "https://api.example.test", runEventToken: "run-event-token", run, modelId: "model", resolveProvider: () => "provider", fetch, - }); + })); const write = persistence.output.write({ type: "text-delta", id: "message", @@ -226,6 +293,13 @@ describe("managed broker persistence", () => { resolveProvider: () => "provider", fetch, }); + let ownedWorkTail = Promise.resolve(); + persistence.bindSessionOwnedWork((operation) => { + const result = operation(); + const settled = result.then(() => undefined, () => undefined); + ownedWorkTail = Promise.all([ownedWorkTail, settled]).then(() => undefined); + return result; + }); const audit = persistence.modelRunEventSink({ type: "AGENT_RUN_MODEL_CALL_CONTEXT", messages: [], @@ -249,9 +323,12 @@ describe("managed broker persistence", () => { ); assertEquals(calls.at(-1)?.status, "failed"); + let ownedWorkSettled = false; + void ownedWorkTail.then(() => ownedWorkSettled = true); let cleanupSettled = false; const cleanup = persistence.cleanup().then(() => cleanupSettled = true); for (let index = 0; index < 20; index += 1) await Promise.resolve(); + assertEquals(ownedWorkSettled, false); assertEquals(cleanupSettled, false); appendRelease.resolve(Response.json({ @@ -265,6 +342,8 @@ describe("managed broker persistence", () => { latest_external_event_sequence: 1, }, })); + await ownedWorkTail; + assertEquals(ownedWorkSettled, true); await cleanup; assertEquals(cleanupSettled, true); }); @@ -273,14 +352,14 @@ describe("managed broker persistence", () => { const calls: Record[] = []; const fetch = successfulFetch(calls); await withMockFetch(fetch, async () => { - const persistence = createManagedBrokerPersistence({ + const persistence = bindForTest(createManagedBrokerPersistence({ apiUrl: "https://api.example.test", runEventToken: "run-event-token", run, modelId: "model", resolveProvider: () => "provider", fetch, - }); + })); await persistence.output.finish({ completed: false, error: new Error("synthetic execution failure"), From 396f0c54c3bbb7ac67769d88c204bf4dbcc93f40 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 20:12:57 +0200 Subject: [PATCH 101/194] fix(agent): capture steering and cleanup methods before discovery --- .../hosted/executor-runtime-prepare.test.ts | 110 +++++++++++++++++- src/agent/hosted/executor-runtime-prepare.ts | 31 +++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 17d65feb9a..b0e80ee7cc 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -8,7 +8,12 @@ import { defineSchema } from "#veryfront/schemas/index.ts"; import type { AgentConfig } from "#veryfront/agent/types.ts"; import { parseRuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; import { createRuntimeAgentFromMarkdownDefinition } from "#veryfront/agent/runtime/agent-markdown-adapter.ts"; -import type { HostToolDefinition, ToolDefinition } from "#veryfront/tool"; +import type { + HostToolDefinition, + HostToolSet, + RemoteToolSource, + ToolDefinition, +} from "#veryfront/tool"; import { registerModelRuntimeResolverRevoker } from "#veryfront/agent/runtime/model-transport.ts"; import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; import { agent } from "#veryfront/agent/factory.ts"; @@ -91,6 +96,7 @@ function fixture( overrides: { grant?: ExecutorRuntimePreparationGrant | null; facades?: Partial; + facadeInstance?: ExecutorRuntimeFacades; config?: Partial; load?: () => Promise; } = {}, @@ -116,7 +122,7 @@ function fixture( source, discovery, grant: overrides.grant === null ? undefined : overrides.grant ?? grant, - facades: { + facades: overrides.facadeInstance ?? { resolveModelRuntime: () => model, hostTools: new Map(), remoteToolSources: new Map(), @@ -596,6 +602,106 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + it("retains the steering preparation method and its original receiver through discovery", async () => { + class Steering { + #calls: string[] = []; + prepare( + { definition }: Parameters< + NonNullable["prepare"] + >[0], + ) { + this.#calls.push("prepare"); + return Promise.resolve({ agent: definition }); + } + refresh() { + return "Synthetic instructions"; + } + get calls() { + return this.#calls; + } + } + const steering = new Steering(); + const f = fixture({ + facades: { projectSteering: steering }, + load: () => { + steering.prepare = () => { + throw new Error("Replaced preparation method"); + }; + return Promise.resolve(runtime()); + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assert(steering.calls.includes("prepare")); + } finally { + await f.owner.close(); + } + }); + + for (const missing of ["prepare", "refresh"] as const) { + it(`refuses inherited steering ${missing} without exposing its facade`, async () => { + let reads = 0; + const steering: Partial> = { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => "Synthetic instructions", + }; + delete steering[missing]; + const f = fixture({ + grant: { ...grant, requiredCapabilities: ["project-steering"] }, + facades: { + projectSteering: steering as NonNullable, + }, + load: () => { + Object.setPrototypeOf(steering, { + get [missing]() { + reads++; + return () => Promise.resolve(undefined); + }, + }); + return Promise.resolve(runtime()); + }, + }); + try { + assertEquals(await prepare(f.owner), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(reads, 0); + } finally { + await f.owner.close(); + } + }); + } + + for (const method of ["own", "prototype"] as const) { + it(`preserves the original receiver for ${method} cleanup methods`, async () => { + class Facades implements ExecutorRuntimeFacades { + #cleanups = 0; + resolveModelRuntime = () => model; + hostTools = new Map(); + remoteToolSources = new Map(); + cleanup() { + this.#cleanups++; + return Promise.resolve(); + } + get cleanups() { + return this.#cleanups; + } + } + const facades = new Facades(); + if (method === "own") { + Object.defineProperty(facades, "cleanup", { value: facades.cleanup, enumerable: true }); + } + const f = fixture({ facadeInstance: facades }); + try { + assertEquals((await prepare(f.owner) as { ok: boolean }).ok, true); + } finally { + await f.owner.close(); + } + assertEquals(facades.cleanups, 1); + }); + } + it("validates preparation requests without inheriting optional model limits", () => { let reads = 0; const request = Object.create({ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 375f5cb0a8..fecdc214cf 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -84,6 +84,8 @@ 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; @@ -237,6 +239,33 @@ function snapshotCheckpointFacade( 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; @@ -268,6 +297,8 @@ export function createExecutorRuntimePreparation(input: Options) { objectSetPrototypeOf(source, null); const facades: ExecutorRuntimeFacades = { ...input.facades, + cleanup: snapshotFacadeMethod(input.facades, "cleanup"), + projectSteering: snapshotSteeringFacade(input.facades.projectSteering), hostTools: new Map(input.facades.hostTools), remoteToolSources: new Map(input.facades.remoteToolSources), toolExposureCheckpoint: snapshotCheckpointFacade(input.facades.toolExposureCheckpoint), From cd288e606a7c873b6a5647b25713e6a7f8bca3de Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 20:27:33 +0200 Subject: [PATCH 102/194] fix(agent): protect serialization and private facade construction --- src/agent/executor/channel.ts | 3 +- src/agent/executor/protocol.ts | 5 +- src/agent/hosted/executor-agent-bridge.ts | 3 +- src/agent/hosted/executor-agent-schema.ts | 5 +- src/agent/hosted/executor-allocator-client.ts | 7 ++- src/agent/hosted/executor-discovery-schema.ts | 3 +- src/agent/hosted/executor-discovery.ts | 3 +- src/agent/hosted/executor-node-transport.ts | 3 +- .../hosted/executor-runtime-prepare.test.ts | 32 +++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 9 ++-- src/agent/hosted/executor-session-schema.ts | 3 +- src/agent/runtime/ag-ui-contract.ts | 3 +- src/agent/runtime/agent-delegation.ts | 5 +- .../runtime/agent-invocation-contract.ts | 3 +- src/agent/runtime/chat-stream-handler.ts | 6 ++- src/agent/runtime/index.ts | 22 ++++---- src/agent/runtime/load-skill-tool.ts | 7 +-- src/agent/runtime/message-adapter.ts | 3 +- src/agent/runtime/project-files-client.ts | 11 ++-- src/agent/runtime/provider-tool-compat.ts | 5 +- src/agent/runtime/repair-tool-call.ts | 5 +- src/agent/runtime/resume-session.ts | 3 +- src/agent/runtime/skill-policy-enforcement.ts | 3 +- src/agent/runtime/sse-utils.ts | 4 +- src/agent/runtime/stream-lifecycle-shadow.ts | 3 +- src/agent/runtime/tool-helpers.ts | 6 ++- src/agent/runtime/upload-url-client.ts | 3 +- src/agent/streaming/chat-ui-message-stream.ts | 3 +- src/agent/streaming/data-stream.ts | 3 +- src/agent/streaming/executor-data-stream.ts | 3 +- src/agent/streaming/lifecycle/reducer.ts | 3 +- .../lifecycle/runtime-provider-adapter.ts | 3 +- src/agent/streaming/lifecycle/tool-input.ts | 3 +- src/agent/streaming/stream-events.ts | 3 +- .../tool-execution-data-event-bridge.ts | 7 ++- src/agent/streaming/tool-input.ts | 3 +- src/security/private-json.ts | 3 ++ src/tool/host-tools.ts | 12 +++-- .../agent/executor-json-intrinsics.test.ts | 51 ++++++++++++++++++ ...runtime-tool-provenance-intrinsics.test.ts | 54 +++++++++++++++++++ 40 files changed, 256 insertions(+), 63 deletions(-) create mode 100644 src/security/private-json.ts create mode 100644 tests/integration/agent/executor-json-intrinsics.test.ts diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index 5108b50b92..d3c3219d88 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; import { @@ -701,7 +702,7 @@ class Channel implements ExecutorChannel { } #retainPayload(value: JsonValue): number { - const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength; + const bytes = new TextEncoder().encode(privateJsonStringify(value)).byteLength; if (this.#retainedBytes + bytes > this.#maxRetainedBytes) { this.#fail("Executor retained payload budget exceeded"); throw new ExecutorProtocolError("Executor retained payload budget exceeded"); diff --git a/src/agent/executor/protocol.ts b/src/agent/executor/protocol.ts index 1f4b99ce12..38462625ff 100644 --- a/src/agent/executor/protocol.ts +++ b/src/agent/executor/protocol.ts @@ -1,3 +1,4 @@ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; @@ -73,7 +74,7 @@ export function encodeExecutorFrame(frame: ExecutorFrame): Uint8Array { if (!snapshot.success || !getExecutorFrameSchema().safeParse(snapshot.value).success) { throw new TypeError("Invalid executor frame"); } - const payload = new TextEncoder().encode(JSON.stringify(snapshot.value)); + const payload = new TextEncoder().encode(privateJsonStringify(snapshot.value)); if (payload.byteLength > EXECUTOR_MAX_FRAME_BYTES - 4) { throw new TypeError("Executor frame exceeds byte limit"); } @@ -126,7 +127,7 @@ export async function* readExecutorFrames( if (payloadOffset === payload.byteLength) { let decoded: unknown; try { - decoded = JSON.parse(decoder.decode(payload)); + decoded = privateJsonParse(decoder.decode(payload)); } catch { throw new ExecutorProtocolError("Invalid executor frame encoding"); } diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index a9f674bfc1..76e8abc396 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ExecutorChannel, ExecutorOperation } from "../executor/channel.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ChatUiMessageChunk } from "#veryfront/chat/types.ts"; @@ -179,7 +180,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { const event = parseExecutorDataEvent(frame.event); terminal ||= event.type === "message-finish" || event.type === "error"; controller.enqueue( - textEncoder.encode(`data: ${JSON.stringify(event)}\n\n`), + textEncoder.encode(`data: ${privateJsonStringify(event)}\n\n`), ); } else if (frame.type === "complete") { if (!terminal || !(await iterator.next()).done) { diff --git a/src/agent/hosted/executor-agent-schema.ts b/src/agent/hosted/executor-agent-schema.ts index c24209afaa..5bc9dc4a8b 100644 --- a/src/agent/hosted/executor-agent-schema.ts +++ b/src/agent/hosted/executor-agent-schema.ts @@ -1,3 +1,4 @@ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema, type JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; @@ -166,14 +167,14 @@ export function parseExecutorAgentData(schema: Schema, input: unknown): T /** Serialize schema-validated values; optional undefined properties are omitted. */ export function executorAgentJson(input: unknown, oversized: FailureCode): JsonValue { - const encoded = JSON.stringify(input); + const encoded = privateJsonStringify(input); if ( encoded === undefined || new TextEncoder().encode(encoded).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES ) { throw new ExecutorAgentError(oversized); } - const snapshot = snapshotBoundedJsonValue(JSON.parse(encoded)); + const snapshot = snapshotBoundedJsonValue(privateJsonParse(encoded)); if (!snapshot.success) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_INPUT"); return snapshot.value; } diff --git a/src/agent/hosted/executor-allocator-client.ts b/src/agent/hosted/executor-allocator-client.ts index faf627553d..11cee1475f 100644 --- a/src/agent/hosted/executor-allocator-client.ts +++ b/src/agent/hosted/executor-allocator-client.ts @@ -1,3 +1,4 @@ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { Buffer } from "node:buffer"; import { lookup } from "node:dns/promises"; import { request as httpsRequest } from "node:https"; @@ -29,7 +30,7 @@ function origin(value: string): URL { } function payload(value: unknown): Buffer { - const encoded = Buffer.from(JSON.stringify(value)); + const encoded = Buffer.from(privateJsonStringify(value)); if (encoded.byteLength > MAX_BYTES) { encoded.fill(0); throw new TypeError("Executor allocator request is too large"); @@ -139,7 +140,9 @@ export function createHostedExecutorAllocatorClient(options: { try { const body = Buffer.concat(chunks, size); try { - responseValue = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + responseValue = privateJsonParse( + new TextDecoder("utf-8", { fatal: true }).decode(body), + ); } finally { body.fill(0); } diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts index a8e77f8454..5ede5317a8 100644 --- a/src/agent/hosted/executor-discovery-schema.ts +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { snapshotOwnDataRecords } from "#veryfront/security/own-data-record.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; @@ -109,7 +110,7 @@ export const getExecutorAgentDefinitionSchema = defineSchema((v) => { }).strict(), ).max(64).optional(), }).strict().refine((value) => - new TextEncoder().encode(JSON.stringify(value)).byteLength <= + new TextEncoder().encode(privateJsonStringify(value)).byteLength <= EXECUTOR_DISCOVERY_MAX_DEFINITION_BYTES ); }); diff --git a/src/agent/hosted/executor-discovery.ts b/src/agent/hosted/executor-discovery.ts index 203af95637..bd155c255a 100644 --- a/src/agent/hosted/executor-discovery.ts +++ b/src/agent/hosted/executor-discovery.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { isAbsolute, join, relative, sep } from "node:path"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { @@ -152,7 +153,7 @@ export function createExecutorDiscovery(input: ExecutorDiscoveryOptions): Execut assertActive(); backend = createNodeExecutorDiscoveryBackend({ projectDir, - cacheKey: JSON.stringify(binding), + cacheKey: privateJsonStringify(binding), }); } loadStarted = true; diff --git a/src/agent/hosted/executor-node-transport.ts b/src/agent/hosted/executor-node-transport.ts index 5106f3e1f5..21d76d80d9 100644 --- a/src/agent/hosted/executor-node-transport.ts +++ b/src/agent/hosted/executor-node-transport.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { type AddressInfo, isIP, type Socket } from "node:net"; @@ -88,7 +89,7 @@ function validateOptions( } if (options.signal?.aborted) throw new Error("Executor transport aborted"); // Fixed tuple ordering and a versioned domain prevent ambiguous identity encodings. - const identity = createHash("sha256").update(JSON.stringify([ + const identity = createHash("sha256").update(privateJsonStringify([ "veryfront-executor-tls", 1, binding.allocationId, diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index b0e80ee7cc..2619597e65 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -674,6 +674,38 @@ describe("executor runtime preparation review regressions", () => { } for (const method of ["own", "prototype"] as const) { + it(`preserves the original receiver for ${method} model resolver methods`, async () => { + class Facades implements ExecutorRuntimeFacades { + #modelCalls = 0; + hostTools = new Map(); + remoteToolSources = new Map(); + resolveModelRuntime() { + this.#modelCalls++; + return model; + } + cleanup() { + return Promise.resolve(); + } + get modelCalls() { + return this.#modelCalls; + } + } + const facades = new Facades(); + if (method === "own") { + Object.defineProperty(facades, "resolveModelRuntime", { + value: facades.resolveModelRuntime, + enumerable: true, + }); + } + const f = fixture({ facadeInstance: facades }); + try { + assertEquals((await prepare(f.owner) as { ok: boolean }).ok, true); + assert(facades.modelCalls > 0); + } finally { + await f.owner.close(); + } + }); + it(`preserves the original receiver for ${method} cleanup methods`, async () => { class Facades implements ExecutorRuntimeFacades { #cleanups = 0; diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index fecdc214cf..8788849a4c 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -225,7 +225,7 @@ function snapshotCheckpointFacade( if (!facade) return undefined; const descriptor = objectGetOwnPropertyDescriptor(facade, "initial"); const initial = descriptor && hasOwn(descriptor, "value") ? descriptor.value as I : undefined; - const persist = facade.persist; + const persist = snapshotFacadeMethod(facade, "persist"); const snapshot = { initial, persist: typeof persist === "function" @@ -295,10 +295,13 @@ export function createExecutorRuntimePreparation(input: Options) { const source = parseRuntimePreparationData(getExecutorDiscoverySourceSchema(), input.source); objectSetPrototypeOf(binding, null); objectSetPrototypeOf(source, null); + const installedModelResolver = input.facades.resolveModelRuntime; const facades: ExecutorRuntimeFacades = { - ...input.facades, + 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), @@ -509,7 +512,7 @@ export function createExecutorRuntimePreparation(input: Options) { }; registerModelRuntimeResolverRevoker( resolveModelRuntime, - () => revokeModelRuntimeResolver(facades.resolveModelRuntime), + () => revokeModelRuntimeResolver(installedModelResolver), ); // The first facade call can reserve resources before throwing. resourcesStarted = true; diff --git a/src/agent/hosted/executor-session-schema.ts b/src/agent/hosted/executor-session-schema.ts index bf4745724c..74794e5ea3 100644 --- a/src/agent/hosted/executor-session-schema.ts +++ b/src/agent/hosted/executor-session-schema.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { isIP } from "node:net"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; @@ -115,7 +116,7 @@ export function parseHostedExecutorData(schema: Schema, value: unknown): T const snapshot = snapshotBoundedJsonValue(value); if ( !snapshot.success || - new TextEncoder().encode(JSON.stringify(snapshot.value)).byteLength > 32 * 1024 + new TextEncoder().encode(privateJsonStringify(snapshot.value)).byteLength > 32 * 1024 ) { throw new Error("Executor session invalid allocator data"); } diff --git a/src/agent/runtime/ag-ui-contract.ts b/src/agent/runtime/ag-ui-contract.ts index 95c3cb7d6f..6e873cb180 100644 --- a/src/agent/runtime/ag-ui-contract.ts +++ b/src/agent/runtime/ag-ui-contract.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, SchemaValidator } from "#veryfront/extensions/schema/index.ts"; import { parseAgUiJsonBody, parseAgUiJsonRequestOrError } from "../ag-ui/request-shared.ts"; @@ -17,7 +18,7 @@ function isRecord(value: unknown): value is Record { function isWithinJsonSizeLimit(value: unknown, maxBytes: number): boolean { try { - return encoder.encode(JSON.stringify(value)).byteLength <= maxBytes; + return encoder.encode(privateJsonStringify(value)).byteLength <= maxBytes; } catch { return false; } diff --git a/src/agent/runtime/agent-delegation.ts b/src/agent/runtime/agent-delegation.ts index de68471879..36fb6c8955 100644 --- a/src/agent/runtime/agent-delegation.ts +++ b/src/agent/runtime/agent-delegation.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { Tool, ToolExecutionContext } from "../../tool/types.ts"; import type { Agent } from "../types.ts"; import { agentAsTool, getAgent } from "../composition/index.ts"; @@ -51,7 +52,9 @@ function buildInvokeAgentPrompt( ): string { if (!context) return prompt; if (Object.keys(context).length === 0) return prompt; - return `${prompt}\n\n\n${JSON.stringify(context)}\n`; + return `${prompt}\n\n\n${ + privateJsonStringify(context) + }\n`; } /** diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index 1437a6be0d..b81cfb4a70 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, RefinementCtx } from "#veryfront/extensions/schema/index.ts"; import { ensureBuiltinSchemaValidator } from "#veryfront/extensions/builtin-extensions.ts"; @@ -21,7 +22,7 @@ const INFERENCE_CREDENTIAL_PATTERN = /^[\x21-\x7e]+$/; function isWithinJsonSizeLimit(value: unknown, maxBytes: number): boolean { try { - return encoder.encode(JSON.stringify(value)).byteLength <= maxBytes; + return encoder.encode(privateJsonStringify(value)).byteLength <= maxBytes; } catch { return false; } diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 27a1222bc0..ae5a987549 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -8,6 +8,8 @@ * @module agent/runtime/chat-stream-handler */ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; + import type { RuntimeStreamPart, RuntimeStreamResult } from "./runtime-tool-types.ts"; import type { ModelRuntime } from "#veryfront/provider/types.ts"; import { @@ -370,12 +372,12 @@ function normalizeToolInputString(input: unknown): string { return input; } - return JSON.stringify(input ?? null) ?? "null"; + return privateJsonStringify(input ?? null) ?? "null"; } function tryParseToolInputObject(input: string): Record | null { try { - const parsed = JSON.parse(stripLeadingEmptyObjectPlaceholder(input)); + const parsed = privateJsonParse(stripLeadingEmptyObjectPlaceholder(input)); return isRecord(parsed) ? parsed : null; } catch { return null; diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index ec8f640cee..919f94a3a6 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -11,6 +11,8 @@ * @module ai/agent/runtime */ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; + import { createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { enterSerializedTurn, @@ -947,7 +949,7 @@ function isTextSseChunk(chunk: Uint8Array): boolean { } try { - const event = JSON.parse(payload.slice("data: ".length)) as { type?: unknown }; + const event = privateJsonParse(payload.slice("data: ".length)) as { type?: unknown }; return event.type === "text-start" || event.type === "text-delta" || event.type === "text-end"; } catch { @@ -962,7 +964,7 @@ function isTextEndSseChunk(chunk: Uint8Array): boolean { } try { - const event = JSON.parse(payload.slice("data: ".length)) as { type?: unknown }; + const event = privateJsonParse(payload.slice("data: ".length)) as { type?: unknown }; return event.type === "text-end"; } catch { return false; @@ -976,7 +978,7 @@ function textDeltaFromSseChunk(chunk: Uint8Array): string | undefined { } try { - const event = JSON.parse(payload.slice("data: ".length)) as Record; + const event = privateJsonParse(payload.slice("data: ".length)) as Record; return event.type === "text-delta" && typeof event.delta === "string" ? event.delta : undefined; } catch { return undefined; @@ -1005,7 +1007,7 @@ function stripTextDeltaPrefixFromSseChunk( } try { - const event = JSON.parse(payload.slice("data: ".length)) as Record; + const event = privateJsonParse(payload.slice("data: ".length)) as Record; if (event.type !== "text-delta" || typeof event.delta !== "string") { return { chunk, remainingPrefixLength }; } @@ -1014,7 +1016,9 @@ function stripTextDeltaPrefixFromSseChunk( return { chunk: undefined, remainingPrefixLength: stripped.remainingPrefixLength }; } return { - chunk: encoder.encode(`data: ${JSON.stringify({ ...event, delta: stripped.text })}\n\n`), + chunk: encoder.encode( + `data: ${privateJsonStringify({ ...event, delta: stripped.text })}\n\n`, + ), remainingPrefixLength: stripped.remainingPrefixLength, }; } catch { @@ -1033,7 +1037,7 @@ function rewriteRecoveryTextSseChunkId( } try { - const event = JSON.parse(payload.slice("data: ".length)) as Record; + const event = privateJsonParse(payload.slice("data: ".length)) as Record; if ( event.type !== "text-start" && event.type !== "text-delta" && event.type !== "text-end" @@ -1043,7 +1047,7 @@ function rewriteRecoveryTextSseChunkId( const id = typeof event.id === "string" && event.id.length > 0 ? `${event.id}:recovery` : fallbackId; - return encoder.encode(`data: ${JSON.stringify({ ...event, id })}\n\n`); + return encoder.encode(`data: ${privateJsonStringify({ ...event, id })}\n\n`); } catch { return chunk; } @@ -1355,7 +1359,7 @@ function synchronizeRuntimeToolInventory( function parseToolResultJson(result: string): unknown { try { - return JSON.parse(result); + return privateJsonParse(result); } catch { return null; } @@ -1382,7 +1386,7 @@ type RuntimeTraceAttributes = Record byteLimit) { throw new RangeError(`Project file response may contain at most ${byteLimit} bytes`); } - return JSON.parse(text); + return privateJsonParse(text); } const reader = response.body.getReader(); @@ -577,7 +578,7 @@ async function readPublicJsonResponseWithinLimit( if (currentBlock && currentBlockLength > 0) { bytes.set(currentBlock.subarray(0, currentBlockLength), offset); } - return JSON.parse(publicProjectFileUtf8Decoder.decode(bytes)); + return privateJsonParse(publicProjectFileUtf8Decoder.decode(bytes)); } /** Return a runtime project file with strict hosted-boundary enforcement. */ @@ -1835,7 +1836,7 @@ async function readBoundedJsonResponse( ); throwIfStrictProjectFilesRequestExpired(requestScope); try { - const value = JSON.parse(text); + const value = privateJsonParse(text); throwIfStrictProjectFilesRequestExpired(requestScope); return value; } catch { @@ -1876,7 +1877,7 @@ async function readApiErrorMessage(response: Response): Promise { success: false; }; try { - const jsonValue = JSON.parse(body); + const jsonValue = privateJsonParse(body); const result = getApiErrorBodySchema().safeParse(jsonValue); parsedJson = result.success ? { success: true, data: result.data } : { success: false }; } catch { @@ -1924,7 +1925,7 @@ async function readStrictApiErrorMessage( success: false; }; try { - const jsonValue = JSON.parse(body); + const jsonValue = privateJsonParse(body); const result = getStrictApiErrorBodySchema().safeParse(jsonValue); parsedJson = result.success ? { success: true, data: result.data } : { success: false }; } catch { diff --git a/src/agent/runtime/provider-tool-compat.ts b/src/agent/runtime/provider-tool-compat.ts index f985cc96db..43869f3b21 100644 --- a/src/agent/runtime/provider-tool-compat.ts +++ b/src/agent/runtime/provider-tool-compat.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ToolDefinition } from "#veryfront/tool"; import type { JsonSchema } from "#veryfront/tool/schema"; @@ -607,8 +608,8 @@ function getMergedPropertySchema( ): unknown { if (schemas.length === 1) return schemas[0]; const [first, ...rest] = schemas; - const serializedFirst = JSON.stringify(first); - if (rest.every((schema) => JSON.stringify(schema) === serializedFirst)) { + const serializedFirst = privateJsonStringify(first); + if (rest.every((schema) => privateJsonStringify(schema) === serializedFirst)) { return first; } return { [keyword]: schemas }; diff --git a/src/agent/runtime/repair-tool-call.ts b/src/agent/runtime/repair-tool-call.ts index d423d9293b..1e5aef10e6 100644 --- a/src/agent/runtime/repair-tool-call.ts +++ b/src/agent/runtime/repair-tool-call.ts @@ -1,3 +1,4 @@ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { isInvalidToolInputError, isNoSuchToolError } from "./runtime-tool-errors.ts"; import type { RuntimeToolCallRepairFunction } from "./runtime-tool-types.ts"; @@ -31,7 +32,7 @@ export const repairToolCall: RuntimeToolCallRepairFunction = async ({ let normalizedQuery = trimmedInput; try { - const parsedInput = JSON.parse(trimmedInput) as unknown; + const parsedInput = privateJsonParse(trimmedInput) as unknown; if (typeof parsedInput === "string") { normalizedQuery = parsedInput.trim(); } @@ -45,6 +46,6 @@ export const repairToolCall: RuntimeToolCallRepairFunction = async ({ return { ...toolCall, - input: JSON.stringify({ query: normalizedQuery }), + input: privateJsonStringify({ query: normalizedQuery }), }; }; diff --git a/src/agent/runtime/resume-session.ts b/src/agent/runtime/resume-session.ts index d7f49ca06c..ecd7e6134a 100644 --- a/src/agent/runtime/resume-session.ts +++ b/src/agent/runtime/resume-session.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { AGENT_ERROR } from "#veryfront/errors"; /** Public API contract for run session status. */ @@ -91,7 +92,7 @@ export interface RunResumeSessionManagerOptions { } function defaultConflictKey(value: unknown): string { - return JSON.stringify(value); + return privateJsonStringify(value); } /** Implement run resume session manager. */ diff --git a/src/agent/runtime/skill-policy-enforcement.ts b/src/agent/runtime/skill-policy-enforcement.ts index 770dd15fab..f37583a76d 100644 --- a/src/agent/runtime/skill-policy-enforcement.ts +++ b/src/agent/runtime/skill-policy-enforcement.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import type { Message } from "../types.ts"; import type { ToolDefinition } from "#veryfront/tool"; import { serverLogger } from "#veryfront/utils"; @@ -250,7 +251,7 @@ export function applySkillActivationResult( function parseToolResultJson(result: string): unknown { try { - return JSON.parse(result); + return privateJsonParse(result); } catch { return null; } diff --git a/src/agent/runtime/sse-utils.ts b/src/agent/runtime/sse-utils.ts index 9990bef022..2d75b2ca00 100644 --- a/src/agent/runtime/sse-utils.ts +++ b/src/agent/runtime/sse-utils.ts @@ -6,6 +6,8 @@ * @module ai/agent/runtime/sse-utils */ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; + // Runtime heuristic: detects a write to an already-closed ReadableStream controller. // Browser/Node and Deno use different messages for the same Web Streams state error. // Keep this narrow so unrelated TypeErrors still surface. @@ -25,7 +27,7 @@ export function sendSSE( event: Record, ): void { try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + controller.enqueue(encoder.encode(`data: ${privateJsonStringify(event)}\n\n`)); } catch (error) { if (isClosedStreamControllerError(error)) { return; diff --git a/src/agent/runtime/stream-lifecycle-shadow.ts b/src/agent/runtime/stream-lifecycle-shadow.ts index 45963261d5..41c2a23cec 100644 --- a/src/agent/runtime/stream-lifecycle-shadow.ts +++ b/src/agent/runtime/stream-lifecycle-shadow.ts @@ -1,3 +1,4 @@ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { stripLeadingEmptyObjectPlaceholder } from "#veryfront/agent/streaming/data-stream.ts"; import { createInitialReducerState, @@ -111,7 +112,7 @@ function normalizeArgumentText(raw: string): string { const stripped = stripLeadingEmptyObjectPlaceholder(raw); if (stripped.length === 0) return ""; try { - return JSON.stringify(JSON.parse(stripped)); + return privateJsonStringify(privateJsonParse(stripped)); } catch { return stripped; } diff --git a/src/agent/runtime/tool-helpers.ts b/src/agent/runtime/tool-helpers.ts index ad622c0681..b9a19f4199 100644 --- a/src/agent/runtime/tool-helpers.ts +++ b/src/agent/runtime/tool-helpers.ts @@ -6,6 +6,8 @@ * @module ai/agent/runtime/tool-helpers */ +import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; + import type { RemoteToolSource, Tool, ToolDefinition, ToolExecutionContext } from "#veryfront/tool"; import { executeTool, isToolVisibleTo, toolRegistry } from "#veryfront/tool"; import { assertLocalToolId, toolToProviderDefinition } from "#veryfront/tool/registry.ts"; @@ -81,7 +83,7 @@ export function parseToolArgs( rawArgs = trimmed; } - const parsed = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs; + const parsed = typeof rawArgs === "string" ? privateJsonParse(rawArgs) : rawArgs; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { args: {}, error: "Tool call arguments must be a JSON object" }; @@ -391,7 +393,7 @@ export async function executeConfiguredTool( function logToolDefinition(name: string, def: ToolDefinition): void { logger.debug( `[AGENT] Tool definition for "${name}":`, - JSON.stringify(def, null, 2), + privateJsonStringify(def, null, 2), ); } diff --git a/src/agent/runtime/upload-url-client.ts b/src/agent/runtime/upload-url-client.ts index a3e5a16d68..924cf1a39e 100644 --- a/src/agent/runtime/upload-url-client.ts +++ b/src/agent/runtime/upload-url-client.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import { NETWORK_ERROR } from "#veryfront/errors"; @@ -86,7 +87,7 @@ async function readApiErrorMessage(response: Response): Promise { success: false; }; try { - const jsonValue = JSON.parse(body); + const jsonValue = privateJsonParse(body); const result = getApiErrorBodySchema().safeParse(jsonValue); parsedJson = result.success ? { success: true, data: result.data } : { success: false }; } catch { diff --git a/src/agent/streaming/chat-ui-message-stream.ts b/src/agent/streaming/chat-ui-message-stream.ts index be0db8a99a..b82e51b8e5 100644 --- a/src/agent/streaming/chat-ui-message-stream.ts +++ b/src/agent/streaming/chat-ui-message-stream.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import type { ChatFinishReason, ChatStreamEvent } from "#veryfront/chat/protocol.ts"; import type { ChatDynamicToolUiPart, @@ -206,7 +207,7 @@ function getParsedStreamedToolInput(inputText: string): Record } try { - const parsed = JSON.parse(normalizedInputText); + const parsed = privateJsonParse(normalizedInputText); return isRecord(parsed) ? Object.fromEntries(Object.entries(parsed)) : {}; } catch { return null; diff --git a/src/agent/streaming/data-stream.ts b/src/agent/streaming/data-stream.ts index 4d5f348ced..d735c105fc 100644 --- a/src/agent/streaming/data-stream.ts +++ b/src/agent/streaming/data-stream.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { serverLogger } from "#veryfront/utils"; import type { AgUiRuntimeStreamEvent } from "../ag-ui/encoder.ts"; @@ -32,7 +33,7 @@ export function parseDataStreamSseEvents(chunk: string): { } try { - return [JSON.parse(payload) as AgUiRuntimeStreamEvent]; + return [privateJsonParse(payload) as AgUiRuntimeStreamEvent]; } catch (error) { logger.warn("Dropped malformed SSE data block", { errorName: error instanceof Error ? error.name : typeof error, diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index e1731d332f..5b78f4a671 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { EXECUTOR_MAX_RETAINED_BYTES } from "../executor/protocol.ts"; import { EXECUTOR_AGENT_MAX_PAYLOAD_BYTES, @@ -11,7 +12,7 @@ function parseBlock(block: string) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } return parseExecutorDataEvent( - JSON.parse(lines.map((line) => line.slice(5).trimStart()).join("\n")), + privateJsonParse(lines.map((line) => line.slice(5).trimStart()).join("\n")), ); } diff --git a/src/agent/streaming/lifecycle/reducer.ts b/src/agent/streaming/lifecycle/reducer.ts index 867a02f9dd..18971ae137 100644 --- a/src/agent/streaming/lifecycle/reducer.ts +++ b/src/agent/streaming/lifecycle/reducer.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { mergeToolCallInput, mergeToolInputDelta, @@ -503,7 +504,7 @@ function closeOpenContent(state: StreamReducerState, emit: FrameEmitter): void { function serializeToolInput(input: unknown): string | null { try { - return JSON.stringify(input ?? null) ?? "null"; + return privateJsonStringify(input ?? null) ?? "null"; } catch { return null; } diff --git a/src/agent/streaming/lifecycle/runtime-provider-adapter.ts b/src/agent/streaming/lifecycle/runtime-provider-adapter.ts index 95e2b2632f..bbc35ecf2c 100644 --- a/src/agent/streaming/lifecycle/runtime-provider-adapter.ts +++ b/src/agent/streaming/lifecycle/runtime-provider-adapter.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { isDynamicTool } from "#veryfront/agent/runtime/tool-helpers.ts"; import type { RuntimeStreamPart } from "#veryfront/agent/runtime/runtime-tool-types.ts"; import { @@ -313,7 +314,7 @@ function toolReadySignals( const streamed = prior?.inputText ?? ""; const finalText = typeof typed.input === "string" ? typed.input - : JSON.stringify(typed.input ?? {}); + : privateJsonStringify(typed.input ?? {}); const merged = mergeToolCallInput(streamed, finalText); const parsed = parseCanonicalToolInput( typeof typed.input === "object" && typed.input !== null && diff --git a/src/agent/streaming/lifecycle/tool-input.ts b/src/agent/streaming/lifecycle/tool-input.ts index 3f77708100..4142153588 100644 --- a/src/agent/streaming/lifecycle/tool-input.ts +++ b/src/agent/streaming/lifecycle/tool-input.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { stripLeadingEmptyObjectPlaceholder } from "#veryfront/agent/streaming/data-stream.ts"; export type CanonicalToolInputParseResult = @@ -17,7 +18,7 @@ export function parseCanonicalToolInput( const normalized = stripLeadingEmptyObjectPlaceholder(input); if (normalized.length === 0) return { ok: false, reason: "invalid" }; try { - const parsed: unknown = JSON.parse(normalized); + const parsed: unknown = privateJsonParse(normalized); return isRecord(parsed) ? { ok: true, value: parsed } : { ok: false, reason: "invalid" }; } catch { return { ok: false, reason: "malformed" }; diff --git a/src/agent/streaming/stream-events.ts b/src/agent/streaming/stream-events.ts index 07d9bcb363..6ba41c4049 100644 --- a/src/agent/streaming/stream-events.ts +++ b/src/agent/streaming/stream-events.ts @@ -1,10 +1,11 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; export class StreamEventEmitter { private encoder = new TextEncoder(); constructor(private controller: ReadableStreamDefaultController) {} emit(event: Record): void { - this.controller.enqueue(this.encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + this.controller.enqueue(this.encoder.encode(`data: ${privateJsonStringify(event)}\n\n`)); } private emitToolEvent( diff --git a/src/agent/streaming/tool-execution-data-event-bridge.ts b/src/agent/streaming/tool-execution-data-event-bridge.ts index 15973ebf80..687fdf3455 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.ts @@ -1,3 +1,4 @@ +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ToolExecutionDataEvent } from "#veryfront/tool/types.ts"; import { AGENT_ERROR } from "#veryfront/errors"; @@ -14,11 +15,13 @@ function serializeToolExecutionDataEvent(event: ToolExecutionDataEvent): Uint8Ar if (typeof event.name === "string" && event.name.length > 0) { const data = Object.hasOwn(event, "value") ? event.value : event.data; return new TextEncoder().encode( - `data: ${JSON.stringify({ type: `data-${event.name}`, data })}\n\n`, + `data: ${privateJsonStringify({ type: `data-${event.name}`, data })}\n\n`, ); } - return new TextEncoder().encode(`data: ${JSON.stringify({ type: "data", data: event })}\n\n`); + return new TextEncoder().encode( + `data: ${privateJsonStringify({ type: "data", data: event })}\n\n`, + ); } function toUint8ArrayChunk(value: unknown): Uint8Array { diff --git a/src/agent/streaming/tool-input.ts b/src/agent/streaming/tool-input.ts index 4800bf208a..3ae914dd98 100644 --- a/src/agent/streaming/tool-input.ts +++ b/src/agent/streaming/tool-input.ts @@ -1,3 +1,4 @@ +import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; const logger = serverLogger.component("agent-tool-input"); @@ -139,7 +140,7 @@ export function parseToolInputObject(input: unknown): Record { if (typeof input === "string") { try { - const parsed = JSON.parse(stripLeadingEmptyObjectPlaceholder(input)); + const parsed = privateJsonParse(stripLeadingEmptyObjectPlaceholder(input)); if (isRecord(parsed)) { return parsed; } diff --git a/src/security/private-json.ts b/src/security/private-json.ts new file mode 100644 index 0000000000..872b488b26 --- /dev/null +++ b/src/security/private-json.ts @@ -0,0 +1,3 @@ +/** JSON operations captured before project discovery can replace global methods. */ +export const privateJsonParse = JSON.parse; +export const privateJsonStringify = JSON.stringify; diff --git a/src/tool/host-tools.ts b/src/tool/host-tools.ts index ff15497128..9a0022a61c 100644 --- a/src/tool/host-tools.ts +++ b/src/tool/host-tools.ts @@ -177,22 +177,26 @@ export function createToolsFromHostDefinitions( try { let materializedTool: Tool | undefined; if (definition.inputSchemaJson) { - materializedTool = dynamicTool({ + const config = { + __proto__: null, id: toolName, description: definition.description, inputSchema: definition.inputSchema, inputSchemaJson: definition.inputSchemaJson, execute, mcp: definition.mcp, - }); + }; + materializedTool = dynamicTool(config); } else if (isSchemaLike(definition.inputSchema)) { - materializedTool = tool({ + const config = { + __proto__: null, id: toolName, description: definition.description, inputSchema: definition.inputSchema, execute, mcp: definition.mcp, - }); + }; + materializedTool = tool(config); } if (materializedTool) { const canonicalRemoteToolName = getRemoteToolProvenance(originalDefinition); diff --git a/tests/integration/agent/executor-json-intrinsics.test.ts b/tests/integration/agent/executor-json-intrinsics.test.ts new file mode 100644 index 0000000000..7fa56c7fb3 --- /dev/null +++ b/tests/integration/agent/executor-json-intrinsics.test.ts @@ -0,0 +1,51 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { executorAgentJson } from "#veryfront/agent/hosted/executor-agent-schema.ts"; +import { readExecutorDataEvents } from "#veryfront/agent/streaming/executor-data-stream.ts"; +import { StreamEventEmitter } from "#veryfront/agent/streaming/stream-events.ts"; + +describe("executor JSON intrinsics", () => { + it("keeps synthetic requests and model events out of replaced global JSON methods", async () => { + const marker = "synthetic-private-json-marker"; + const request = { messages: [{ text: marker }] }; + const originalParse = JSON.parse; + const originalStringify = JSON.stringify; + let observations = 0; + let snapshot: unknown; + const received: unknown[] = []; + const stream = new ReadableStream({ + start(controller) { + const emitter = new StreamEventEmitter(controller); + queueMicrotask(() => { + emitter.emitTextDelta("text-1", marker); + emitter.emitFinish(); + controller.close(); + }); + }, + }); + try { + JSON.stringify = ((value: unknown) => { + const encoded = originalStringify(value); + if (encoded?.includes(marker)) observations++; + return encoded; + }) as typeof JSON.stringify; + JSON.parse = ((text: string) => { + if (text.includes(marker)) observations++; + return originalParse(text); + }) as typeof JSON.parse; + snapshot = executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + for await (const event of readExecutorDataEvents(stream, new AbortController().signal)) { + received.push(event); + } + } finally { + JSON.stringify = originalStringify; + JSON.parse = originalParse; + } + assertEquals(snapshot, request); + assertEquals(received, [{ type: "text-delta", id: "text-1", delta: marker }, { + type: "message-finish", + }]); + assertEquals(observations, 0); + }); +}); diff --git a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts index 4a71657611..0f34fb8e90 100644 --- a/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts +++ b/tests/integration/agent/runtime-tool-provenance-intrinsics.test.ts @@ -22,6 +22,60 @@ import { } from "#veryfront/tool/remote-tool-provenance.ts"; describe("runtime tool provenance intrinsics", () => { + for (const kind of ["typed", "dynamic"] as const) { + it(`keeps ${kind} factory configs out of inherited optional-field getters`, () => { + const definition = { + description: "Synthetic private tool", + inputSchema: defineSchema((v) => v.object({}))(), + ...(kind === "dynamic" ? { inputSchemaJson: { type: "object" as const } } : {}), + execute: () => ({ ok: true }), + }; + const fields = [ + "outputSchema", + "toModelOutput", + "allowUnknownSchema", + "delegatedIntegrationTools", + ]; + const originals = fields.map((field) => + Object.getOwnPropertyDescriptor(Object.prototype, field) + ); + const defineProperty = Object.defineProperty; + const ownDescriptor = Object.getOwnPropertyDescriptor; + let exposures = 0; + let materialized = false; + try { + for (const field of fields) { + defineProperty(Object.prototype, field, { + configurable: true, + get() { + if (typeof ownDescriptor(this, "execute")?.value === "function") exposures++; + return undefined; + }, + set(value: unknown) { + defineProperty(this, field, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + }, + }); + } + materialized = + createToolsFromHostDefinitions({ private: definition }).private !== undefined; + } finally { + for (let index = 0; index < fields.length; index++) { + const field = fields[index]!; + const original = originals[index]; + if (original) defineProperty(Object.prototype, field, original); + else delete (Object.prototype as Record)[field]; + } + } + assertEquals(materialized, true); + assertEquals(exposures, 0); + }); + } + it("keeps fallback facade entries private from inherited array setters", async () => { const definition = { description: "Synthetic fetch tool", From 504ff135843ff40e87a2321897ea376a85c209d1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 20:36:01 +0200 Subject: [PATCH 103/194] test(agent): guard promise lifecycle against paired intrinsic replacement --- ...executor-runtime-private-lifecycle.test.ts | 30 ++++++++- .../agent/private-promise-intrinsics.test.ts | 67 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/integration/agent/private-promise-intrinsics.test.ts diff --git a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts index d44ebcec93..159f2e4ad3 100644 --- a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts +++ b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts @@ -162,6 +162,7 @@ describe("executor runtime private lifecycle", () => { }); let facadeCleanups = 0; let discoveryCleanups = 0; + const cleanupFinished = Promise.withResolvers(); const discovery = createExecutorDiscovery({ binding, source, @@ -215,11 +216,15 @@ describe("executor runtime private lifecycle", () => { }), cleanup: () => { facadeCleanups++; - return Promise.resolve(); + return cleanupFinished.promise; }, }, }); const originalThen = Promise.prototype.then; + const originalPromiseConstructor = Object.getOwnPropertyDescriptor( + Promise.prototype, + "constructor", + )!; const originalAbort = AbortController.prototype.abort; const originalMin = Math.min; let stepLimitOverrides = 0; @@ -239,13 +244,32 @@ describe("executor runtime private lifecycle", () => { deadline: Date.now() + 30_000, }); assertEquals((result as { ok?: boolean }).ok, true, JSON.stringify(result)); - Promise.prototype.then = (() => Promise.resolve()) as typeof originalThen; + Object.defineProperty(Promise.prototype, "constructor", { + configurable: true, + writable: true, + value: function ProjectPromise() {}, + }); + Promise.prototype.then = (function (fulfilled: ((value: unknown) => unknown) | undefined) { + fulfilled?.(undefined); + return Promise.resolve(); + }) as typeof originalThen; AbortController.prototype.abort = () => {}; - await owner.close(); + const closing = owner.close(); + await { + then(resolve: () => void) { + setTimeout(resolve, 0); + }, + }; + assertEquals(facadeCleanups, 1); + assertEquals(discoveryCleanups, 0); + cleanupFinished.resolve(); + await closing; } finally { + Object.defineProperty(Promise.prototype, "constructor", originalPromiseConstructor); Promise.prototype.then = originalThen; AbortController.prototype.abort = originalAbort; Math.min = originalMin; + cleanupFinished.resolve(); await owner.close(); } assertEquals(owner.signal.aborted, true); diff --git a/tests/integration/agent/private-promise-intrinsics.test.ts b/tests/integration/agent/private-promise-intrinsics.test.ts new file mode 100644 index 0000000000..8ed7845603 --- /dev/null +++ b/tests/integration/agent/private-promise-intrinsics.test.ts @@ -0,0 +1,67 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { chainPrivatePromise } from "#veryfront/security/private-promise.ts"; + +describe("private promise lifecycle intrinsics", () => { + it("awaits original inputs and callback results with replaced constructor and then methods", async () => { + const input = Promise.withResolvers(); + const output = Promise.withResolvers(); + const originalConstructor = Object.getOwnPropertyDescriptor(Promise.prototype, "constructor")!; + const originalThen = Promise.prototype.then; + const nativePromise = Promise; + const originalResolve = Promise.resolve; + let callbackEntered = false; + let completed = false; + let consumerCompleted = false; + let chained: Promise | undefined; + let consumer: Promise | undefined; + // An own thenable supplies a test turn without relying on the replaced methods. + const turn = { + then(resolve: () => void) { + setTimeout(resolve, 0); + }, + }; + await turn; + try { + Object.defineProperty(Promise.prototype, "constructor", { + configurable: true, + writable: true, + value: function ProjectPromise() {}, + }); + Promise.prototype.then = (function (fulfilled: ((value: unknown) => unknown) | undefined) { + fulfilled?.(undefined); + return Reflect.apply(originalResolve, nativePromise, []); + }) as typeof originalThen; + chained = chainPrivatePromise(input.promise, () => { + callbackEntered = true; + return output.promise; + }); + Reflect.apply(originalThen, chained, [() => { + completed = true; + }]); + consumer = (async () => { + await chained; + consumerCompleted = true; + })(); + await turn; + assertEquals(callbackEntered, false); + assertEquals(completed, false); + assertEquals(consumerCompleted, false); + input.resolve(); + await turn; + assertEquals(callbackEntered, true); + assertEquals(completed, false); + assertEquals(consumerCompleted, false); + } finally { + Object.defineProperty(Promise.prototype, "constructor", originalConstructor); + Promise.prototype.then = originalThen; + input.resolve(); + output.resolve("finished"); + await chained; + await consumer; + } + assertEquals(await chained, "finished"); + assertEquals(completed, true); + assertEquals(consumerCompleted, true); + }); +}); From 94a7e0b98ce3d9363f0b7af25f21f2a8010d53a6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 20:38:25 +0200 Subject: [PATCH 104/194] test(agent): document intentional thenable scheduling fixtures --- .../agent/executor-runtime-private-lifecycle.test.ts | 2 +- tests/integration/agent/private-promise-intrinsics.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts index 159f2e4ad3..b31bcc6339 100644 --- a/tests/integration/agent/executor-runtime-private-lifecycle.test.ts +++ b/tests/integration/agent/executor-runtime-private-lifecycle.test.ts @@ -256,7 +256,7 @@ describe("executor runtime private lifecycle", () => { AbortController.prototype.abort = () => {}; const closing = owner.close(); await { - then(resolve: () => void) { + then(resolve: () => void) { // NOSONAR S7739: intentional PromiseLike scheduling fixture. setTimeout(resolve, 0); }, }; diff --git a/tests/integration/agent/private-promise-intrinsics.test.ts b/tests/integration/agent/private-promise-intrinsics.test.ts index 8ed7845603..4168106ed3 100644 --- a/tests/integration/agent/private-promise-intrinsics.test.ts +++ b/tests/integration/agent/private-promise-intrinsics.test.ts @@ -17,7 +17,7 @@ describe("private promise lifecycle intrinsics", () => { let consumer: Promise | undefined; // An own thenable supplies a test turn without relying on the replaced methods. const turn = { - then(resolve: () => void) { + then(resolve: () => void) { // NOSONAR S7739: intentional PromiseLike scheduling fixture. setTimeout(resolve, 0); }, }; From 90b12abc5b384703d4a955377cad7a804dd5771c Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 20:40:52 +0200 Subject: [PATCH 105/194] fix(agent): preserve public output contracts and SSE usage --- deno.json | 2 +- docs/api-reference/veryfront/agent.md | 37 ++++++++++--------- .../runtime/project-files-client.test.ts | 7 +++- .../service/managed-broker-handler.test.ts | 26 +++++++++++++ src/agent/service/managed-broker-handler.ts | 30 ++++++++++----- src/agent/service/managed-broker.ts | 6 ++- 6 files changed, 76 insertions(+), 32 deletions(-) diff --git a/deno.json b/deno.json index f696668f24..1a5cbf17af 100644 --- a/deno.json +++ b/deno.json @@ -532,7 +532,7 @@ "lint:ci-typescript": "deno check --unstable-sloppy-imports --frozen --config=scripts/test.deno.json scripts/ci/npm-compatibility-artifact.ts scripts/ci/registry-release-integrity.ts scripts/ci/registry-release-integrity.test.ts scripts/ci/publish-npm-packages.test.ts tests/integration/ci/node-executor-coverage.test.ts tests/integration/ci/merge-quality-gate-workflow.test.ts tests/integration/ci/npm-compatibility-artifact-workflow.test.ts tests/integration/ci/npm-compatibility-artifact.test.ts tests/integration/ci/runtime-inference-critical-flow-packed-artifact.test.ts tests/integration/ci/registry-release-smoke.test.ts tests/integration/ci/registry-release-workflow.test.ts && deno fmt --check --line-width=100 --config=scripts/test.deno.json scripts/ci/npm-compatibility-artifact.ts scripts/ci/registry-release-integrity.ts scripts/ci/registry-release-integrity.test.ts tests/integration/ci/ && deno lint --config=scripts/test.deno.json scripts/test/coverage-node-executor.mjs scripts/ci/npm-compatibility-artifact.ts scripts/ci/registry-release-integrity.ts scripts/ci/registry-release-integrity.test.ts tests/integration/ci/", "fmt": "deno fmt src/ cli/ react/ templates/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-compiler-options.ts scripts/build/dnt-jsx-runtime.test.ts scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", "fmt:check": "deno fmt --check src/ cli/ react/ templates/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-compiler-options.ts scripts/build/dnt-jsx-runtime.test.ts scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", - "typecheck": "deno task generate:manifests:check && deno check src/index.ts cli/main.ts src/server/index.ts src/routing/api/index.ts src/rendering/index.ts src/platform/index.ts src/platform/adapters/index.ts src/build/index.ts src/build/production-build/index.ts src/transforms/index.ts src/config/index.ts src/utils/index.ts src/data/index.ts src/security/index.ts src/middleware/index.ts src/server/handlers/dev/index.ts src/server/handlers/request/api/index.ts src/rendering/cache/index.ts src/rendering/cache/stores/index.ts src/rendering/rsc/actions/index.ts src/html/index.ts src/html/hydration-script-builder/runtime/main.ts src/modules/index.ts src/proxy/main.ts src/react/components/ui/index.ts src/chat/index.ts src/markdown/index.ts src/mdx/index.ts src/fs/index.ts src/oauth/index.ts src/agent/index.ts src/agent/service/route-export.check.ts src/eval/index.ts src/tool/index.ts src/workflow/index.ts src/prompt/index.ts src/resource/index.ts src/runs/index.ts src/mcp/index.ts src/provider/index.ts", + "typecheck": "deno task generate:manifests:check && deno check src/index.ts cli/main.ts src/server/index.ts src/routing/api/index.ts src/rendering/index.ts src/platform/index.ts src/platform/adapters/index.ts src/build/index.ts src/build/production-build/index.ts src/transforms/index.ts src/config/index.ts src/utils/index.ts src/data/index.ts src/security/index.ts src/middleware/index.ts src/server/handlers/dev/index.ts src/server/handlers/request/api/index.ts src/rendering/cache/index.ts src/rendering/cache/stores/index.ts src/rendering/rsc/actions/index.ts src/html/index.ts src/html/hydration-script-builder/runtime/main.ts src/modules/index.ts src/proxy/main.ts src/react/components/ui/index.ts src/chat/index.ts src/markdown/index.ts src/mdx/index.ts src/fs/index.ts src/oauth/index.ts src/agent/index.ts src/agent/service/route-export.check.ts src/agent/service/managed-broker.ts src/agent/hosted/executor-runtime-entrypoint.ts src/eval/index.ts src/tool/index.ts src/workflow/index.ts src/prompt/index.ts src/resource/index.ts src/runs/index.ts src/mcp/index.ts src/provider/index.ts", "verify": "deno task generate:manifests:check && deno task fmt:check && deno task lint && deno task lint:ci-typescript && deno task lint:style && deno task lint:chat-composability && deno task lint:chat-ratchets && deno task lint:esm-sh-codemod && deno task lint:cli-boundary && deno task lint:wildcard-exports && deno task lint:barrel-jsdoc && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:ban-zod && deno task lint:cwd-relative-test-reads && deno task lint:test-semantic-dispositions && deno task lint:anti-slop && deno task lint:render-mode-defaults && deno task lint:testing-front-door && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:validate && deno task typecheck && deno task typecheck:consumer && deno task test && deno task test:scripts && deno task test:e2e:binary", "verify:quick": "deno task generate:manifests:check && deno task test:layout && deno task fmt:check && deno task lint && deno task lint:ci-typescript && deno task lint:style && deno task lint:chat-composability && deno task lint:chat-ratchets && deno task lint:esm-sh-codemod && deno task lint:cli-boundary && deno task lint:wildcard-exports && deno task lint:barrel-jsdoc && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:ban-zod && deno task lint:cwd-relative-test-reads && deno task lint:test-semantic-dispositions && deno task lint:anti-slop && deno task lint:render-mode-defaults && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:validate && deno task typecheck", "typecheck:consumer": "deno run --allow-read --allow-run --allow-env --allow-write scripts/typecheck/run-consumer-typecheck.ts", diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 25f683b404..06b4cbaffc 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -2015,21 +2015,22 @@ import { #### Types -| Name | Description | Source | -| ------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | -| `ManagedAgentBrokerIngressAuthority` | Private parsed request and credentials available only to trusted broker preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedAgentExecutorRequest` | Detached bounded application data, without HTTP objects or broker credentials. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedAgentIngressResult` | Preserve the distinct durable and direct AG-UI ingress contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | -| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedNodeBrokerHandler` | Trusted broker route handler and optional retirement hook. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | -| `ManagedNodeBrokerPool` | Broker admission and settlement lifecycle retained by the HTTP server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | +| Name | Description | Source | +| ------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | +| `ManagedAgentBrokerIngressAuthority` | Private parsed request and credentials available only to trusted broker preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedAgentExecutorRequest` | Detached bounded application data, without HTTP objects or broker credentials. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedAgentIngressResult` | Preserve the distinct durable and direct AG-UI ingress contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedBrokerOutput` | Acknowledging output writes and terminal finalization for a canonical run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | +| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedNodeBrokerHandler` | Trusted broker route handler and optional retirement hook. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | +| `ManagedNodeBrokerPool` | Broker admission and settlement lifecycle retained by the HTTP server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | diff --git a/src/agent/runtime/project-files-client.test.ts b/src/agent/runtime/project-files-client.test.ts index 69a8a83841..3369b99f2c 100644 --- a/src/agent/runtime/project-files-client.test.ts +++ b/src/agent/runtime/project-files-client.test.ts @@ -1047,6 +1047,7 @@ Deno.test("strict runtime project files preserve timeout identity across request Deno.test("strict project file requests enforce monotonic timeouts around synchronous fetch work", async () => { const responseCancelled = createDeferred(); + let fetchCalls = 0; const response = streamResponse( [new TextEncoder().encode('{"path":"src/index.ts","content":"ok"}')], {}, @@ -1055,9 +1056,10 @@ Deno.test("strict project file requests enforce monotonic timeouts around synchr const error = await assertRejects(() => getStrictRuntimeProjectFile({ ...baseOptions, - timeoutMs: 1, + timeoutMs: TEST_IN_FLIGHT_DEADLINE_MS, fetch: async () => { - const busyUntil = performance.now() + 20; + fetchCalls += 1; + const busyUntil = performance.now() + TEST_IN_FLIGHT_DEADLINE_MS + 20; while (performance.now() < busyUntil) { // Deliberately block timer delivery to verify the monotonic check. } @@ -1069,6 +1071,7 @@ Deno.test("strict project file requests enforce monotonic timeouts around synchr assertEquals((error as Error).name, "TimeoutError"); assertStringIncludes(getErrorMessage(error), "request timed out"); + assertEquals(fetchCalls, 1, "the deadline must expire after synchronous fetch work starts"); await responseCancelled.promise; }); diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index 20070e9a32..49837e3f8c 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -9,6 +9,7 @@ import type { import { createManagedBrokerHandler } from "./managed-broker-handler.ts"; import { ExecutorAgentError } from "../hosted/executor-agent-schema.ts"; import { resolveConversationHostedStreamErrorState } from "../conversation/hosted-terminal.ts"; +import { agUiSseEventTypes, parseAgUiSseResponse } from "../ag-ui/sse-parser.ts"; const projectId = "00000000-0000-4000-8000-000000000005"; const userId = "00000000-0000-4000-8000-000000000006"; @@ -114,6 +115,7 @@ function runtimeFixture( usageCaptureStatus: "complete" as const, }, }; + yield { type: "text-delta", id: "assistant-message", delta: "done" } as const; yield { type: "finish", finishReason: "stop", @@ -405,6 +407,30 @@ describe("managed broker handler", () => { assertEquals(f.managed.active, 0); }); + it("preserves finish usage metadata in the SSE RunFinished event", async () => { + const f = await handler("sse", { finishWithUsage: true }); + const response = await f.managed.handle(f.first.request); + f.fixture.release(); + const parsed = await parseAgUiSseResponse(response); + await f.managed.close(); + const finished = parsed.events.find((event) => event.type === agUiSseEventTypes.runFinished); + const metadata = finished?.metadata as Record | undefined; + + assertEquals({ + inputTokens: metadata?.inputTokens, + outputTokens: metadata?.outputTokens, + totalTokens: metadata?.totalTokens, + usageCaptureStatus: metadata?.usageCaptureStatus, + finishReason: metadata?.finishReason, + }, { + inputTokens: 12, + outputTokens: 7, + totalTokens: 19, + usageCaptureStatus: "complete", + finishReason: "stop", + }); + }); + it("releases failed setup reservations and maps the error without diagnostics", async () => { const f = await handler("detached", { failStart: true }); const response = await f.managed.handle(f.first.request); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index 3348afba76..aceb4364b4 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -1,4 +1,7 @@ -import type { HostedChatRuntimeStreamInput } from "../hosted/chat-runtime-contract.ts"; +import type { + HostedChatRuntimeFinishPart, + HostedChatRuntimeStreamInput, +} from "../hosted/chat-runtime-contract.ts"; import { ExecutorAgentError, getExecutorAgentFailureCodeSchema, @@ -234,6 +237,19 @@ function managedRunKey(ingress: BrokerRuntimeAgentIngress buildManagedBrokerMessageMetadata(input.runtime, part), + }); const completion = Promise.withResolvers(); let natural = false; let cleanup: Promise | undefined; @@ -339,13 +357,7 @@ async function runDetached( for await ( const chunk of result.toUIMessageStream({ messageMetadata({ part }) { - const metadata = buildChatStreamChunkMessageMetadata({ - agentId: runtime.definition.id, - agentName: runtime.definition.name, - agentAvatarUrl: runtime.definition.avatarUrl, - modelId: runtime.modelId, - part: { type: part.type, totalUsage: part.totalUsage }, - }); + const metadata = buildManagedBrokerMessageMetadata(runtime, part); terminalMetadata = { modelId: metadata.modelId, ...(metadata.usage ? { usage: metadata.usage } : {}), diff --git a/src/agent/service/managed-broker.ts b/src/agent/service/managed-broker.ts index a3e097237a..55ad0abba8 100644 --- a/src/agent/service/managed-broker.ts +++ b/src/agent/service/managed-broker.ts @@ -5,7 +5,10 @@ export { type ManagedExecutorRuntime, type ManagedExecutorStartInput, } from "../hosted/managed-executor-broker.ts"; -export { createManagedBrokerPersistence } from "../hosted/managed-broker-persistence.ts"; +export { + createManagedBrokerPersistence, + type ManagedBrokerOutput, +} from "../hosted/managed-broker-persistence.ts"; export { createHostedExecutorAllocatorClient } from "../hosted/executor-allocator-client.ts"; export { connectExecutorTransport, @@ -13,7 +16,6 @@ export { } from "../hosted/executor-node-transport.ts"; export { createManagedBrokerHandler, - type ManagedBrokerOutput, type ManagedExecutorStarter, } from "./managed-broker-handler.ts"; export { From c73fda227b6fd899449a937afa59c797f61b0e1e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 20:57:55 +0200 Subject: [PATCH 106/194] fix(agent): protect prepared configs and private message streams --- src/agent/ag-ui/runtime-support.ts | 3 +- src/agent/factory.ts | 12 ++- src/agent/hosted/default-chat-runtime.ts | 11 +- src/agent/hosted/executor-agent-bridge.ts | 7 +- src/agent/middleware/cache/cache.ts | 3 +- src/agent/middleware/security/validator.ts | 21 ++-- src/agent/runtime/call-context.ts | 7 +- src/agent/runtime/chat-stream-handler.ts | 3 +- src/agent/runtime/index.ts | 12 ++- src/agent/runtime/input-utils.ts | 3 +- src/agent/runtime/message-adapter.ts | 9 +- src/agent/runtime/message-file-url-refresh.ts | 7 +- .../runtime/runtime-stream-cancel.test.ts | 25 +++++ src/agent/streaming/data-stream.ts | 3 +- src/agent/streaming/executor-data-stream.ts | 3 +- .../streaming/fork-runtime-step-state.ts | 3 +- .../tool-execution-data-event-bridge.test.ts | 23 ++++ .../tool-execution-data-event-bridge.ts | 3 +- src/security/private-array.ts | 22 ++++ src/security/private-stream.ts | 47 ++++++++ .../executor-runtime-private-facades.test.ts | 13 +++ .../agent/private-stream-intrinsics.test.ts | 101 ++++++++++++++++++ 22 files changed, 304 insertions(+), 37 deletions(-) create mode 100644 src/security/private-array.ts create mode 100644 src/security/private-stream.ts create mode 100644 tests/integration/agent/private-stream-intrinsics.test.ts diff --git a/src/agent/ag-ui/runtime-support.ts b/src/agent/ag-ui/runtime-support.ts index d635fc594c..c6553b7270 100644 --- a/src/agent/ag-ui/runtime-support.ts +++ b/src/agent/ag-ui/runtime-support.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import type { Message } from "../types.ts"; import type { AgUiRuntimeRequest } from "../runtime/ag-ui-contract.ts"; @@ -46,7 +47,7 @@ export function normalizeAgUiRuntimeMessages( ): Message[] { const toolNamesById = new Map(); - return messages.map((message) => { + return mapPrivateArray(messages, (message) => { const parts: Message["parts"] = []; switch (message.role) { diff --git a/src/agent/factory.ts b/src/agent/factory.ts index 0515989b09..48c8566bfc 100644 --- a/src/agent/factory.ts +++ b/src/agent/factory.ts @@ -76,6 +76,7 @@ const IntrinsicReflectApply = Reflect.apply; const IntrinsicArrayFilter = Array.prototype.filter; const IntrinsicObjectEntries = Object.entries; const IntrinsicObjectKeys = Object.keys; +const IntrinsicObjectSetPrototypeOf = Object.setPrototypeOf; const STREAMING_HEADERS: Record = { "Content-Type": "text/event-stream", @@ -561,11 +562,13 @@ function createAgent( : { providerTools: resolveProviderToolsConfiguration(config) }), model: resolveConfiguredAgentModel(config.model), }; + const preserveToolCatalog = options.runtimeOptions?.preserveToolCatalog === true; + if (preserveToolCatalog) IntrinsicObjectSetPrototypeOf(publicConfig, null); registerConfiguredLocalTools(config); let mergedToolsConfig: AgentConfig["tools"]; - if (options.runtimeOptions?.preserveToolCatalog === true) { + if (preserveToolCatalog) { if (config.tools === true || delegates !== undefined) { throw new TypeError( "A prevalidated agent requires an explicit tool catalog without delegates", @@ -573,6 +576,7 @@ function createAgent( } ensureBuiltinSchemaValidator(); mergedToolsConfig = { ...(config.tools ?? {}) }; + IntrinsicObjectSetPrototypeOf(mergedToolsConfig, null); } else { mergedToolsConfig = resolveToolsConfiguration({ config, @@ -593,12 +597,14 @@ function createAgent( assertPlatformCompatible(config, id); - const runtime = new AgentRuntime(id, { + const runtimeConfig = { ...publicConfig, tools: mergedToolsConfig, system: augmentedSystem, middleware: resolvedMiddleware, - }, options.runtimeOptions); + }; + if (preserveToolCatalog) IntrinsicObjectSetPrototypeOf(runtimeConfig, null); + const runtime = new AgentRuntime(id, runtimeConfig, options.runtimeOptions); const agentInstance = createAgentInstance({ id, diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 70c6e998f5..e27654a81b 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -66,6 +66,7 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts" const apply = Reflect.apply; const TypeErrorConstructor = TypeError; const objectEntries = Object.entries; +const objectSetPrototypeOf = Object.setPrototypeOf; function mapOwnRecord( input: Record, @@ -378,6 +379,7 @@ function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): Age remoteToolSource: input.toolAssembly.remoteToolSources[0], }), }; + objectSetPrototypeOf(runtimeConfig, null); return runtimeConfig; } @@ -613,8 +615,13 @@ export function createPreparedHostedRuntimeAgent( input: PreparedHostedRuntimeAgentOptions, runtimeOptions: AgentRuntimeInternalOptions, ) { - return createEphemeralAgentWithRuntimeOptions(createRuntimeAgentConfig(input), { + const resolvedRuntimeOptions = { ...runtimeOptions, modelCallThinking: runtimeOptions.modelCallThinking ?? input.options.thinking, - }); + }; + objectSetPrototypeOf(resolvedRuntimeOptions, null); + return createEphemeralAgentWithRuntimeOptions( + createRuntimeAgentConfig(input), + resolvedRuntimeOptions, + ); } diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 76e8abc396..198033bf39 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -1,3 +1,4 @@ +import { cancelPrivateStream, isPrivateStreamLocked } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ExecutorChannel, ExecutorOperation } from "../executor/channel.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; @@ -36,7 +37,7 @@ async function startExecutorRuntimeStream( // that lifetime; a noncooperative setup is fenced by the handler deadline. const stream = await start(); if (signal.aborted) { - await stream.cancel().catch(() => {}); + await cancelPrivateStream(stream).catch(() => {}); throw new ExecutorAgentError("ABORTED"); } return stream; @@ -92,7 +93,9 @@ export function createExecutorAgentOperations(options: { } finally { // A return while suspended at ready can precede acquisition of the // SSE reader. Close that unconsumed source as well as the runtime. - if (stream && !stream.locked) await stream.cancel().catch(() => {}); + if (stream && !isPrivateStreamLocked(stream)) { + await cancelPrivateStream(stream).catch(() => {}); + } if (ownsRuntime) { try { await options.cleanup?.(); diff --git a/src/agent/middleware/cache/cache.ts b/src/agent/middleware/cache/cache.ts index eae22d28b2..7f27e921eb 100644 --- a/src/agent/middleware/cache/cache.ts +++ b/src/agent/middleware/cache/cache.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import type { AgentMiddleware, AgentResponse, Message } from "#veryfront/agent/types.ts"; import { isStatefulTurn } from "#veryfront/agent/middleware/turn-validation.ts"; import { @@ -293,7 +294,7 @@ function defaultKeyGenerator(input: string, context?: Record): function toCacheableInputString(input: string | Message[]): string { if (typeof input === "string") return input; return JSON.stringify( - input.map((message) => { + mapPrivateArray(input, (message) => { const { id, timestamp, ...rest } = message; return { ...rest, diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 570ed0d68d..70a4c9333e 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { isDeepStrictEqual } from "node:util"; import type { AgentContext, @@ -744,7 +745,7 @@ function providerSystemMessages(system: AgentSystem): Message[] { const layers = typeof system === "string" ? [{ role: "system" as const, content: system }] : system; - return layers.map((layer, index) => ({ + return mapPrivateArray(layers, (layer, index) => ({ id: `provider-system-${index}`, role: "system", parts: [{ type: "text", text: layer.content }], @@ -819,11 +820,11 @@ function copySanitizedTextPart(part: MessagePart, text: string): MessagePart { function sanitizeStructuredInput(validator: InputValidator, messages: Message[]): Message[] { let changed = false; - const sanitizedMessages = messages.map((message) => { + const sanitizedMessages = mapPrivateArray(messages, (message) => { if (!VALIDATED_INPUT_ROLES.has(message.role)) return message; let messageChanged = false; - let parts = message.parts.map((part) => { + let parts = mapPrivateArray(message.parts, (part) => { if (!isTextPart(part)) return part; const sanitized = sanitizeTextToFixpoint(validator, part.text); if (sanitized === part.text) return part; @@ -922,7 +923,7 @@ function sanitizeMergedRuns(validator: InputValidator, messages: Message[]): Mes } if (rewrites.size === 0) return messages; - return messages.map((message) => { + return mapPrivateArray(messages, (message) => { const parts = rewrites.get(message); if (parts === undefined) return message; const rewritten = { ...message, parts }; @@ -1460,10 +1461,12 @@ export function securityMiddleware( .filter((message) => message.role === "system"); // Separate occurrences even when a memory adapter returns the same // object twice. Current inputs are appended after historical messages. - const callerMessages = messages.map((message) => - message.role === "system" - ? { id: message.id, role: message.role, parts: message.parts } - : message + const callerMessages = mapPrivateArray( + messages, + (message) => + message.role === "system" + ? { id: message.id, role: message.role, parts: message.parts } + : message, ); const currentSystemMessages = new Set(); for (let index = messages.length - 1; index >= 0; index--) { @@ -1626,7 +1629,7 @@ export function securityMiddleware( } const approvedMessages = typeof context.input === "string" ? undefined - : context.input.map((message) => ({ id: message.id, role: message.role })); + : mapPrivateArray(context.input, (message) => ({ id: message.id, role: message.role })); // A middleware later in the chain can still replace `context.input` or // mutate a message in place after this middleware approved it, and the diff --git a/src/agent/runtime/call-context.ts b/src/agent/runtime/call-context.ts index e952a675c9..c3d865e93a 100644 --- a/src/agent/runtime/call-context.ts +++ b/src/agent/runtime/call-context.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; /** * Agent Call Context * @@ -450,7 +451,7 @@ function removeStructuredCacheControls( messages: readonly ChatSystemMessage[], anthropicProviderAlias: string, ): ChatSystemMessage[] { - return messages.map((message) => { + return mapPrivateArray(messages, (message) => { const providerOptions = snapshotOwnEnumerableDataRecord( message.providerOptions, "Structured system message providerOptions", @@ -520,7 +521,7 @@ function applyStructuredCacheTtl( } const breakpointIndex = messages.length - 1; - const cacheMetadata = messages.map((message) => { + const cacheMetadata = mapPrivateArray(messages, (message) => { const providerOptions = snapshotOwnEnumerableDataRecord( message.providerOptions, "Structured system message providerOptions", @@ -555,7 +556,7 @@ function applyStructuredCacheTtl( breakpointIndexes.slice(-ANTHROPIC_MAX_CACHE_BREAKPOINTS), ); - return messages.map((message, index) => { + return mapPrivateArray(messages, (message, index) => { const { providerOptions, cacheProviderBuckets, undefinedCacheProviderBuckets } = cacheMetadata[index]!; const shouldAddCanonicalBreakpoint = addCanonicalBreakpoint && index === breakpointIndex; diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index ae5a987549..3eafd2dfb6 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,3 +1,4 @@ +import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; /** * Model Runtime Stream Handler * @@ -107,7 +108,7 @@ export interface RuntimeStreamErrorEvent extends Record { function wrapRuntimeProviderReadableStream( stream: ReadableStream, ): ReadableStream { - const reader = stream.getReader(); + const reader = getPrivateStreamReader(stream); let released = false; const releaseReader = () => { if (released) return; diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 919f94a3a6..a80d93cc9e 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -12,6 +12,7 @@ */ import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { @@ -364,6 +365,7 @@ const ObjectDefineProperty = Object.defineProperty; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const ObjectGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectSetPrototypeOf = Object.setPrototypeOf; const ObjectHasOwn = Object.hasOwn; const ObjectIs = Object.is; const ObjectKeys = Object.keys; @@ -1733,6 +1735,7 @@ export class AgentRuntime { : { status: "absent" }; this.id = id; this.config = { ...config }; + if (ObjectGetPrototypeOf(config) === null) ObjectSetPrototypeOf(this.config, null); // Agents are stateless by default (see docs/guides/memory-and-streaming.md): // with no `memory` config, calls never share conversation history, so @@ -1768,7 +1771,7 @@ export class AgentRuntime { private async restoreInputReplayMetadata(inputMessages: Message[]): Promise { const checkpoints = getRuntimeProviderReplayCheckpoints(this.config); if (!checkpoints?.length) return; - const history = (await this.memory.getMessages()).map(cloneMessageForCommit); + const history = mapPrivateArray(await this.memory.getMessages(), cloneMessageForCommit); applyProviderReplayCheckpointsToMessages([...history, ...inputMessages], checkpoints); } @@ -1957,7 +1960,7 @@ export class AgentRuntime { rollback: () => Promise; finalized: Promise; }> { - const committedInputMessages = inputMessages.map((message) => { + const committedInputMessages = mapPrivateArray(inputMessages, (message) => { const cloned = cloneMessageForCommit(message); propagateSyntheticMessageMarks(message, cloned); return isRuntimeGeneratedUserMessage(message) @@ -2006,7 +2009,7 @@ export class AgentRuntime { // provenance detached from every object the transaction receives, while // preserving replay metadata that determines provider message boundaries. if (validateTurnMessages || validateProjectedMessages) { - validated = validated.map((message) => { + validated = mapPrivateArray(validated, (message) => { const snapshot = cloneMessageForCommit(message); propagateSyntheticMessageMarks(message, snapshot); if (isRuntimeGeneratedUserMessage(message)) markRuntimeGeneratedUserMessage(snapshot); @@ -2687,6 +2690,7 @@ export class AgentRuntime { ...this.config, __vfToolLoadingMode: hasToolReplacements ? "eager" : toolLoadingResolution.mode, }; + if (ObjectGetPrototypeOf(this.config) === null) ObjectSetPrototypeOf(runConfig, null); const runtimeStepConfig: AgentConfig = hasToolReplacements ? { ...runConfig, @@ -2697,6 +2701,7 @@ export class AgentRuntime { sandbox: undefined, } : runConfig; + if (ObjectGetPrototypeOf(this.config) === null) ObjectSetPrototypeOf(runtimeStepConfig, null); const runtimeStepToolLoading = resolveRuntimeToolLoading(runtimeStepConfig); const allowedRemoteToolNames = hasToolReplacements ? undefined @@ -3362,6 +3367,7 @@ export class AgentRuntime { ...this.config, __vfToolLoadingMode: toolLoadingResolution.mode, }; + if (ObjectGetPrototypeOf(this.config) === null) ObjectSetPrototypeOf(runtimeStepConfig, null); const allowedRemoteToolNames = getRuntimeAllowedRemoteTools(this.config); const forwardedRemoteToolDefinitions = getRuntimeForwardedIntegrationToolDefs(this.config); const remoteToolSources = getRuntimeRemoteToolSources(this.config, undefined, this.id); diff --git a/src/agent/runtime/input-utils.ts b/src/agent/runtime/input-utils.ts index db9d03f9c6..613c969eba 100644 --- a/src/agent/runtime/input-utils.ts +++ b/src/agent/runtime/input-utils.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import type { Message } from "#veryfront/agent/types.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors"; import { @@ -68,7 +69,7 @@ export function normalizeInput(input: string | Message[]): Message[] { return [message]; } - return input.map((msg, index) => { + return mapPrivateArray(input, (msg, index) => { if (typeof msg.id === "string" && msg.id.trim().length === 0) { throw INVALID_ARGUMENT.create({ detail: "Message id cannot be empty." }); } diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index 728261b813..bff98d683d 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { getProviderModelMessageSourceId, isRecord } from "#veryfront/chat/conversation.ts"; import { @@ -325,12 +326,12 @@ function toJsonValue(value: unknown): JsonValue { } if (Array.isArray(value)) { - return value.map((item) => toJsonValue(item)); + return mapPrivateArray(value, (item) => toJsonValue(item)); } if (isRecord(value)) { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, toJsonValue(entry)]), + mapPrivateArray(Object.entries(value), ([key, entry]) => [key, toJsonValue(entry)]), ); } @@ -453,7 +454,7 @@ export function createToolResultPart(part: { } function joinTextParts(textParts: readonly ProviderTextPart[]): string { - return textParts.map((part) => part.text).join("\n\n"); + return mapPrivateArray(textParts, (part) => part.text).join("\n\n"); } function collectAgentRuntimeProviderContentParts( @@ -698,7 +699,7 @@ function createProviderMessagesFromAgentRuntimeMessage( export function convertProviderMessagesToAgentRuntimeMessages( messages: readonly ProviderModelMessage[], ): AgentRuntimeMessage[] { - return messages.map((message, index) => ({ + return mapPrivateArray(messages, (message, index) => ({ id: createAgentRuntimeMessageId(message, index), role: message.role, parts: convertContentToAgentRuntimeParts(message), diff --git a/src/agent/runtime/message-file-url-refresh.ts b/src/agent/runtime/message-file-url-refresh.ts index f7fc23e009..bc6d339a00 100644 --- a/src/agent/runtime/message-file-url-refresh.ts +++ b/src/agent/runtime/message-file-url-refresh.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { type ChatUiMessage, type FileUIPartWithUpload, @@ -72,13 +73,13 @@ export async function resolveRuntimeMessageFileUrls( const urlByUploadId = new Map>(); return Promise.all( - messages.map(async (message) => { + mapPrivateArray(messages, async (message) => { if (!message.parts.some((part) => getUploadId(part))) { return message; } const parts = await Promise.all( - message.parts.map(async (part) => { + mapPrivateArray(message.parts, async (part) => { const uploadId = getUploadId(part); if (!uploadId) return part; @@ -188,7 +189,7 @@ export async function inlineRuntimeMessageFileContents( } } - return messages.map((message, messageIndex) => { + return mapPrivateArray(messages, (message, messageIndex) => { if (!message.parts.some((part) => shouldInlineFileContent(part))) { return message; } diff --git a/src/agent/runtime/runtime-stream-cancel.test.ts b/src/agent/runtime/runtime-stream-cancel.test.ts index d5bae1892f..71a608fb56 100644 --- a/src/agent/runtime/runtime-stream-cancel.test.ts +++ b/src/agent/runtime/runtime-stream-cancel.test.ts @@ -94,6 +94,31 @@ function settleAbortedRun(): Promise { } describe("agent runtime stream cancellation (#2334)", () => { + it("copies turn messages without consulting an overridden array mapper", async () => { + let reads = 0; + const messages = [{ + id: "synthetic-message", + role: "user" as const, + parts: [{ type: "text" as const, text: "Synthetic private input" }], + }]; + Object.defineProperty(messages, "map", { + get() { + reads++; + return Array.prototype.map; + }, + }); + const model = scriptedModel([{ text: "Synthetic answer" }]); + const runtime = new AgentRuntime("private-messages", { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + }, { + resolveModelRuntime: () => model, + }); + await Array.fromAsync(await runtime.stream(messages)); + assertEquals(reads, 0); + assertEquals(model.calls.length, 1); + }); + it("reserves completion before producer work and joins failure finalization after cancellation", async () => { const entered = Promise.withResolvers(); const unblock = Promise.withResolvers(); diff --git a/src/agent/streaming/data-stream.ts b/src/agent/streaming/data-stream.ts index d735c105fc..d613ad7c79 100644 --- a/src/agent/streaming/data-stream.ts +++ b/src/agent/streaming/data-stream.ts @@ -1,3 +1,4 @@ +import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { serverLogger } from "#veryfront/utils"; import type { AgUiRuntimeStreamEvent } from "../ag-ui/encoder.ts"; @@ -50,7 +51,7 @@ export function parseDataStreamSseEvents(chunk: string): { export async function* streamDataStreamEvents( stream: ReadableStream, ): AsyncGenerator { - const reader = stream.getReader(); + const reader = getPrivateStreamReader(stream); const decoder = new TextDecoder(); let remainder = ""; let completed = false; diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index 5b78f4a671..bc584c74e7 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -1,3 +1,4 @@ +import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { EXECUTOR_MAX_RETAINED_BYTES } from "../executor/protocol.ts"; import { @@ -21,7 +22,7 @@ export async function* readExecutorDataEvents( stream: ReadableStream, signal: AbortSignal, ) { - const reader = stream.getReader(); + const reader = getPrivateStreamReader(stream); const validator = new TextDecoder("utf-8", { fatal: true }); const decoder = new TextDecoder("utf-8", { fatal: true }); let pending = new Uint8Array(4096); diff --git a/src/agent/streaming/fork-runtime-step-state.ts b/src/agent/streaming/fork-runtime-step-state.ts index 69dc4f1088..01546b5cf5 100644 --- a/src/agent/streaming/fork-runtime-step-state.ts +++ b/src/agent/streaming/fork-runtime-step-state.ts @@ -1,3 +1,4 @@ +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { isRecord } from "#veryfront/chat/conversation.ts"; import type { AgentResponse, Message as AgentMessage } from "../schemas/index.ts"; import { AGENT_ERROR } from "#veryfront/errors"; @@ -181,7 +182,7 @@ function buildFallbackAgentRuntimeMessages( baseMessages: readonly AgentMessage[], state: StreamedStepState, ): AgentMessage[] { - const messages: AgentMessage[] = baseMessages.map((message) => ({ + const messages: AgentMessage[] = mapPrivateArray(baseMessages, (message) => ({ ...message, parts: [...message.parts], })); diff --git a/src/agent/streaming/tool-execution-data-event-bridge.test.ts b/src/agent/streaming/tool-execution-data-event-bridge.test.ts index 730ca72e7a..cea7725052 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.test.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.test.ts @@ -12,6 +12,29 @@ function requireStreamController( } describe("createToolExecutionDataEventBridgeStream", () => { + it("does not expose its source through an overridden reader factory", async () => { + let reads = 0; + const baseStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("Synthetic private output")); + controller.close(); + }, + }); + Object.defineProperty(baseStream, "getReader", { + get() { + reads++; + return ReadableStream.prototype.getReader; + }, + }); + const stream = createToolExecutionDataEventBridgeStream({ + baseStream, + installPublisher: () => {}, + }); + const chunks = await Array.fromAsync(stream); + assertEquals(reads, 0); + assertEquals(new TextDecoder().decode(chunks[0]), "Synthetic private output"); + }); + it("emits published tool data events before forwarding upstream data stream chunks", async () => { const encoder = new TextEncoder(); const decoder = new TextDecoder(); diff --git a/src/agent/streaming/tool-execution-data-event-bridge.ts b/src/agent/streaming/tool-execution-data-event-bridge.ts index 687fdf3455..bb4156c6d8 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.ts @@ -1,3 +1,4 @@ +import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ToolExecutionDataEvent } from "#veryfront/tool/types.ts"; import { AGENT_ERROR } from "#veryfront/errors"; @@ -57,7 +58,7 @@ export function createToolExecutionDataEventBridgeStream( controller.enqueue(serializeToolExecutionDataEvent(event)); }); - const reader = input.baseStream.getReader(); + const reader = getPrivateStreamReader(input.baseStream); baseReader = reader; void (async () => { diff --git a/src/security/private-array.ts b/src/security/private-array.ts new file mode 100644 index 0000000000..a249ef96c0 --- /dev/null +++ b/src/security/private-array.ts @@ -0,0 +1,22 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const hasOwn = Object.hasOwn; + +/** Map private arrays without consulting caller-visible methods or array species. */ +export function mapPrivateArray( + values: readonly T[], + mapper: (value: T, index: number, values: readonly T[]) => U, +): U[] { + const length = values.length; + const output: U[] = []; + output.length = length; + for (let index = 0; index < length; index++) { + if (!hasOwn(values, index)) continue; + defineOwnDataProperty(output, index, mapper(values[index]!, index, values), { + enumerable: true, + configurable: true, + writable: true, + }); + } + return output; +} diff --git a/src/security/private-stream.ts b/src/security/private-stream.ts new file mode 100644 index 0000000000..781cea3d50 --- /dev/null +++ b/src/security/private-stream.ts @@ -0,0 +1,47 @@ +import { observePrivatePromise } from "#veryfront/security/private-promise.ts"; + +const apply = Reflect.apply; +const setPrototypeOf = Object.setPrototypeOf; +const freeze = Object.freeze; +const streamGetReader = ReadableStream.prototype.getReader; +const streamCancel = ReadableStream.prototype.cancel; +const streamLocked = Object.getOwnPropertyDescriptor(ReadableStream.prototype, "locked")!.get!; +const readerRead = ReadableStreamDefaultReader.prototype.read; +const readerCancel = ReadableStreamDefaultReader.prototype.cancel; +const readerReleaseLock = ReadableStreamDefaultReader.prototype.releaseLock; +const readerClosed = Object.getOwnPropertyDescriptor( + ReadableStreamDefaultReader.prototype, + "closed", +)!.get!; + +/** Keep private stream consumption independent of replaced Web Streams methods. */ +export function getPrivateStreamReader( + stream: ReadableStream, +): ReadableStreamDefaultReader { + const reader = apply(streamGetReader, stream, []) as ReadableStreamDefaultReader; + const facade: ReadableStreamDefaultReader = { + read: () => + observePrivatePromise(apply(readerRead, reader, []) as Promise>), + cancel: (reason?: unknown) => + observePrivatePromise(apply(readerCancel, reader, [reason]) as Promise), + releaseLock: () => { + apply(readerReleaseLock, reader, []); + }, + get closed() { + return observePrivatePromise(apply(readerClosed, reader, []) as Promise); + }, + }; + setPrototypeOf(facade, null); + return freeze(facade); +} + +export function cancelPrivateStream( + stream: ReadableStream, + reason?: unknown, +): Promise { + return observePrivatePromise(apply(streamCancel, stream, [reason]) as Promise); +} + +export function isPrivateStreamLocked(stream: ReadableStream): boolean { + return apply(streamLocked, stream, []) as boolean; +} diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 8c950d2756..663a3fbd28 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -86,6 +86,7 @@ describe("private executor facades", () => { "projectSteering", ); const originalMapGet = Map.prototype.get; + const originalDelegates = Object.getOwnPropertyDescriptor(Object.prototype, "delegates"); let hiddenExecutions = 0; let remoteExecutions = 0; let exposedFacadeValues = 0; @@ -137,6 +138,15 @@ describe("private executor facades", () => { signal: new AbortController().signal, backend: { load: () => { + Object.defineProperty(Object.prototype, "delegates", { + configurable: true, + get() { + if (this.id === "veryfront-hosted-runtime" && this.tools?.visible?.execute) { + exposedFacadeValues++; + } + return undefined; + }, + }); Object.defineProperty(Object.prototype, "projectSteering", { configurable: true, get() { @@ -272,6 +282,9 @@ describe("private executor facades", () => { Set.prototype.add = originalSetAdd; Array.prototype.reduce = originalReduce; Array.prototype[Symbol.iterator] = originalArrayIterator; + if (originalDelegates) { + Object.defineProperty(Object.prototype, "delegates", originalDelegates); + } else delete (Object.prototype as Record).delegates; if (originalOwnerAgentId) { Object.defineProperty(Object.prototype, "ownerAgentId", originalOwnerAgentId); } else { diff --git a/tests/integration/agent/private-stream-intrinsics.test.ts b/tests/integration/agent/private-stream-intrinsics.test.ts new file mode 100644 index 0000000000..031e724511 --- /dev/null +++ b/tests/integration/agent/private-stream-intrinsics.test.ts @@ -0,0 +1,101 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createToolExecutionDataEventBridgeStream } from "#veryfront/agent/streaming/tool-execution-data-event-bridge.ts"; +import "#veryfront/schemas/_test-setup.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; + +describe("private stream intrinsics", () => { + it("keeps raw turn messages out of a replaced array mapper", async () => { + const marker = "synthetic-private-turn-marker"; + const originalMap = Array.prototype.map; + const apply = Reflect.apply; + const isArray = Array.isArray; + let exposures = 0; + const model = scriptedModel([{ text: "Synthetic answer" }]); + const runtime = new AgentRuntime("private-messages", { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + }, { resolveModelRuntime: () => model }); + try { + Array.prototype.map = function (this: unknown[], ...args) { + for (let index = 0; index < this.length; index++) { + const message = this[index] as { parts?: unknown[] } | null; + if (!message || !isArray(message.parts)) continue; + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if ((message.parts[partIndex] as { text?: unknown } | null)?.text === marker) { + exposures++; + } + } + } + return apply(originalMap, this, args); + } as typeof originalMap; + await Array.fromAsync( + await runtime.stream([{ + id: "synthetic-message", + role: "user", + parts: [{ type: "text", text: marker }], + }]), + ); + } finally { + Array.prototype.map = originalMap; + } + assertEquals(model.calls.length, 1); + assertEquals(exposures, 0); + }); + + it("reads and cancels private output without invoking replaced reader methods", async () => { + const getReader = ReadableStream.prototype.getReader; + const read = ReadableStreamDefaultReader.prototype.read; + const cancel = ReadableStreamDefaultReader.prototype.cancel; + const releaseLock = ReadableStreamDefaultReader.prototype.releaseLock; + const cancelStream = ReadableStream.prototype.cancel; + const apply = Reflect.apply; + let exposures = 0; + let cancellations = 0; + let text = ""; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("Synthetic private output")); + }, + cancel() { + cancellations++; + }, + }); + try { + ReadableStream.prototype.getReader = function (this: ReadableStream, ...args) { + exposures++; + return apply(getReader, this, args); + } as typeof getReader; + ReadableStreamDefaultReader.prototype.read = function () { + exposures++; + return apply(read, this, []); + }; + ReadableStreamDefaultReader.prototype.cancel = function (reason) { + exposures++; + return apply(cancel, this, [reason]); + }; + ReadableStreamDefaultReader.prototype.releaseLock = function () { + exposures++; + apply(releaseLock, this, []); + }; + const output = createToolExecutionDataEventBridgeStream({ + baseStream: source, + installPublisher: () => {}, + }); + const reader = apply(getReader, output, []); + const chunk = await apply(read, reader, []); + text = new TextDecoder().decode(chunk.value); + apply(releaseLock, reader, []); + await apply(cancelStream, output, []); + } finally { + ReadableStream.prototype.getReader = getReader; + ReadableStreamDefaultReader.prototype.read = read; + ReadableStreamDefaultReader.prototype.cancel = cancel; + ReadableStreamDefaultReader.prototype.releaseLock = releaseLock; + } + assertEquals(text, "Synthetic private output"); + assertEquals(cancellations, 1); + assertEquals(exposures, 0); + }); +}); From 2967e995c3787b260a4009c159b876081ffc9b2f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:01:25 +0200 Subject: [PATCH 107/194] fix(agent): keep private output out of stream controller hooks --- src/agent/runtime/chat-stream-handler.ts | 15 +++-- src/agent/runtime/index.ts | 8 ++- src/agent/runtime/sse-utils.ts | 5 +- src/agent/streaming/stream-events.ts | 6 +- .../tool-execution-data-event-bridge.ts | 16 +++-- src/security/private-stream.ts | 34 ++++++++++ .../agent/private-stream-intrinsics.test.ts | 66 +++++++++++++++++++ 7 files changed, 135 insertions(+), 15 deletions(-) diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 3eafd2dfb6..b8b0e9e3de 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,3 +1,9 @@ +import { + closePrivateStream, + enqueuePrivateStream, + errorPrivateStream, + PrivateReadableStream, +} from "#veryfront/security/private-stream.ts"; import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; /** * Model Runtime Stream Handler @@ -115,18 +121,19 @@ function wrapRuntimeProviderReadableStream( released = true; reader.releaseLock(); }; - return new ReadableStream( + return new PrivateReadableStream( { async pull(controller) { try { const next = await reader.read(); if (next.done) { releaseReader(); - controller.close(); - } else controller.enqueue(next.value); + closePrivateStream(controller); + } else enqueuePrivateStream(controller, next.value); } catch (error) { releaseReader(); - controller.error( + errorPrivateStream( + controller, isStatefulTurnCycleError(error) ? error : createRuntimeProviderStreamFailure(error), ); } diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index a80d93cc9e..2abbf1757e 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1,3 +1,4 @@ +import { enqueuePrivateStream } from "#veryfront/security/private-stream.ts"; /** * Agent Runtime - Core execution engine * @@ -3564,7 +3565,7 @@ export class AgentRuntime { : { chunk: textChunk, remainingPrefixLength: remainingSsePrefixLength }; remainingSsePrefixLength = stripped.remainingPrefixLength; if (stripped.chunk !== undefined) { - controller.enqueue(stripped.chunk); + enqueuePrivateStream(controller, stripped.chunk); } } } @@ -3625,7 +3626,7 @@ export class AgentRuntime { ); for (const output of deferredRecoveryOutput) { if (output.kind === "sse" && !output.isTextEvent) { - controller.enqueue(output.chunk); + enqueuePrivateStream(controller, output.chunk); } } deferredRecoveryOutput.length = 0; @@ -3657,7 +3658,8 @@ export class AgentRuntime { const stepController = deferredRecoveryOutput === undefined ? controller : { enqueue(chunk: Uint8Array) { if (releasedDeferredRecoveryOutput) { - controller.enqueue( + enqueuePrivateStream( + controller, releasedRecoveryReplacementTextPartId !== undefined ? rewriteRecoveryTextSseChunkId( chunk, diff --git a/src/agent/runtime/sse-utils.ts b/src/agent/runtime/sse-utils.ts index 2d75b2ca00..6ea479641a 100644 --- a/src/agent/runtime/sse-utils.ts +++ b/src/agent/runtime/sse-utils.ts @@ -1,3 +1,4 @@ +import { closePrivateStream, enqueuePrivateStream } from "#veryfront/security/private-stream.ts"; /** * SSE (Server-Sent Events) Utilities * @@ -27,7 +28,7 @@ export function sendSSE( event: Record, ): void { try { - controller.enqueue(encoder.encode(`data: ${privateJsonStringify(event)}\n\n`)); + enqueuePrivateStream(controller, encoder.encode(`data: ${privateJsonStringify(event)}\n\n`)); } catch (error) { if (isClosedStreamControllerError(error)) { return; @@ -39,7 +40,7 @@ export function sendSSE( export function closeSSEStream(controller: ReadableStreamDefaultController): void { try { - controller.close(); + closePrivateStream(controller); } catch (error) { if (isClosedStreamControllerError(error)) { return; diff --git a/src/agent/streaming/stream-events.ts b/src/agent/streaming/stream-events.ts index 6ba41c4049..81460755ce 100644 --- a/src/agent/streaming/stream-events.ts +++ b/src/agent/streaming/stream-events.ts @@ -1,3 +1,4 @@ +import { enqueuePrivateStream } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; export class StreamEventEmitter { private encoder = new TextEncoder(); @@ -5,7 +6,10 @@ export class StreamEventEmitter { constructor(private controller: ReadableStreamDefaultController) {} emit(event: Record): void { - this.controller.enqueue(this.encoder.encode(`data: ${privateJsonStringify(event)}\n\n`)); + enqueuePrivateStream( + this.controller, + this.encoder.encode(`data: ${privateJsonStringify(event)}\n\n`), + ); } private emitToolEvent( diff --git a/src/agent/streaming/tool-execution-data-event-bridge.ts b/src/agent/streaming/tool-execution-data-event-bridge.ts index bb4156c6d8..7a523b21f6 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.ts @@ -1,3 +1,9 @@ +import { + closePrivateStream, + enqueuePrivateStream, + errorPrivateStream, + PrivateReadableStream, +} from "#veryfront/security/private-stream.ts"; import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ToolExecutionDataEvent } from "#veryfront/tool/types.ts"; @@ -48,14 +54,14 @@ export function createToolExecutionDataEventBridgeStream( let baseReader: ReadableStreamDefaultReader | null = null; let closed = false; - return new ReadableStream({ + return new PrivateReadableStream({ start(controller) { input.installPublisher((event) => { if (closed) { return; } - controller.enqueue(serializeToolExecutionDataEvent(event)); + enqueuePrivateStream(controller, serializeToolExecutionDataEvent(event)); }); const reader = getPrivateStreamReader(input.baseStream); @@ -69,17 +75,17 @@ export function createToolExecutionDataEventBridgeStream( break; } - controller.enqueue(toUint8ArrayChunk(value)); + enqueuePrivateStream(controller, toUint8ArrayChunk(value)); } if (!closed) { closed = true; - controller.close(); + closePrivateStream(controller); } } catch (error) { if (!closed) { closed = true; - controller.error(error); + errorPrivateStream(controller, error); } } finally { input.installPublisher(() => {}); diff --git a/src/security/private-stream.ts b/src/security/private-stream.ts index 781cea3d50..e549fdcc1b 100644 --- a/src/security/private-stream.ts +++ b/src/security/private-stream.ts @@ -45,3 +45,37 @@ export function cancelPrivateStream( export function isPrivateStreamLocked(stream: ReadableStream): boolean { return apply(streamLocked, stream, []) as boolean; } + +const enqueue = ReadableStreamDefaultController.prototype.enqueue; +const close = ReadableStreamDefaultController.prototype.close; +const error = ReadableStreamDefaultController.prototype.error; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; + +export const PrivateReadableStream = ReadableStream; + +type ControllerOperation = (...args: never[]) => unknown; + +function controllerMethod( + controller: ReadableStreamDefaultController, + key: string, + native: ControllerOperation, +): ControllerOperation { + // Internal forwarding controllers provide own methods; native controllers inherit theirs. + const descriptor = getOwnPropertyDescriptor(controller, key); + return descriptor && hasOwn(descriptor, "value") && typeof descriptor.value === "function" + ? descriptor.value + : native; +} + +export function enqueuePrivateStream(controller: ReadableStreamDefaultController, value: T) { + apply(controllerMethod(controller, "enqueue", enqueue), controller, [value]); +} + +export function closePrivateStream(controller: ReadableStreamDefaultController) { + apply(controllerMethod(controller, "close", close), controller, []); +} + +export function errorPrivateStream(controller: ReadableStreamDefaultController, reason: unknown) { + apply(controllerMethod(controller, "error", error), controller, [reason]); +} diff --git a/tests/integration/agent/private-stream-intrinsics.test.ts b/tests/integration/agent/private-stream-intrinsics.test.ts index 031e724511..61f91146b5 100644 --- a/tests/integration/agent/private-stream-intrinsics.test.ts +++ b/tests/integration/agent/private-stream-intrinsics.test.ts @@ -98,4 +98,70 @@ describe("private stream intrinsics", () => { assertEquals(cancellations, 1); assertEquals(exposures, 0); }); + + it("keeps private output out of replaced reader and controller methods", async () => { + const originalGetReader = ReadableStream.prototype.getReader; + const originalTee = ReadableStream.prototype.tee; + const originalRead = ReadableStreamDefaultReader.prototype.read; + const originalEnqueue = ReadableStreamDefaultController.prototype.enqueue; + const marker = "Synthetic private output"; + const baseStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(marker)); + controller.close(); + }, + }); + let exposures = 0; + let intercepted: ReadableStream | undefined; + let result = ""; + try { + ReadableStreamDefaultReader.prototype.read = function () { + return Reflect.apply(originalRead, this, []).then((value) => { + if ( + value.value instanceof Uint8Array && new TextDecoder().decode(value.value) === marker + ) { + exposures++; + } + return value; + }); + }; + ReadableStreamDefaultController.prototype.enqueue = function (value) { + if (value instanceof Uint8Array && new TextDecoder().decode(value) === marker) exposures++; + return Reflect.apply(originalEnqueue, this, [value]); + }; + ReadableStream.prototype.getReader = (function ( + this: ReadableStream, + options?: ReadableStreamGetReaderOptions, + ) { + if (this === baseStream) { + exposures++; + const branches = Reflect.apply(originalTee, this, []) as ReadableStream[]; + intercepted = branches[0]; + return Reflect.apply(originalGetReader, branches[1], [options]); + } + return Reflect.apply(originalGetReader, this, [options]); + }) as typeof originalGetReader; + const stream = createToolExecutionDataEventBridgeStream({ + baseStream, + installPublisher: () => {}, + }); + const reader = Reflect.apply(originalGetReader, stream, []); + try { + while (true) { + const next = await Reflect.apply(originalRead, reader, []); + if (next.done) break; + result += new TextDecoder().decode(next.value); + } + } finally { + reader.releaseLock(); + } + } finally { + ReadableStream.prototype.getReader = originalGetReader; + ReadableStreamDefaultReader.prototype.read = originalRead; + ReadableStreamDefaultController.prototype.enqueue = originalEnqueue; + if (intercepted) assertEquals(await new Response(intercepted).text(), marker); + } + assertEquals(result, marker); + assertEquals(exposures, 0); + }); }); From 25d69e23145cc222e95a5ff146e7788abef01ecc Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Tue, 8 Sep 2026 21:03:51 +0200 Subject: [PATCH 108/194] fix(agent): adopt broker shutdown failures explicitly --- src/agent/service/managed-node-broker.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/agent/service/managed-node-broker.ts b/src/agent/service/managed-node-broker.ts index 2d7666f701..a5b4806fc6 100644 --- a/src/agent/service/managed-node-broker.ts +++ b/src/agent/service/managed-node-broker.ts @@ -41,11 +41,7 @@ export async function startNodeManagedAgentBroker(options: { const beginShutdown = () => { shuttingDown = true; if (!shutdown) { - try { - shutdown = Promise.resolve(options.broker.shutdown()); - } catch (error) { - shutdown = Promise.reject(error); - } + shutdown = (async () => await options.broker.shutdown())(); } void shutdown.catch(() => {}); return shutdown; From 99801860c72abc4d657622dc016c73074bcfc9ab Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:08:11 +0200 Subject: [PATCH 109/194] fix(agent): isolate private stream construction and controllers --- src/agent/hosted/executor-agent-bridge.ts | 8 +- src/agent/runtime/chat-stream-handler.ts | 6 +- src/agent/runtime/index.ts | 8 +- .../tool-execution-data-event-bridge.ts | 6 +- src/security/private-stream.test.ts | 39 +++++++ src/security/private-stream.ts | 102 +++++++++++++++--- .../agent/private-stream-intrinsics.test.ts | 51 +++++++++ 7 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 src/security/private-stream.test.ts diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 198033bf39..661bd9f593 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -1,4 +1,8 @@ -import { cancelPrivateStream, isPrivateStreamLocked } from "#veryfront/security/private-stream.ts"; +import { + cancelPrivateStream, + createPrivateReadableStream, + isPrivateStreamLocked, +} from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ExecutorChannel, ExecutorOperation } from "../executor/channel.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; @@ -172,7 +176,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { if (consumed) throw new ExecutorAgentError("EXECUTOR_AGENT_ALREADY_STARTED"); consumed = true; let terminal = false; - const stream = new ReadableStream({ + const stream = createPrivateReadableStream({ async pull(controller) { try { const next = await iterator.next(); diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index b8b0e9e3de..05c0cbddaf 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,10 +1,10 @@ import { closePrivateStream, + createPrivateReadableStream, enqueuePrivateStream, errorPrivateStream, - PrivateReadableStream, + getPrivateStreamReader, } from "#veryfront/security/private-stream.ts"; -import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; /** * Model Runtime Stream Handler * @@ -121,7 +121,7 @@ function wrapRuntimeProviderReadableStream( released = true; reader.releaseLock(); }; - return new PrivateReadableStream( + return createPrivateReadableStream( { async pull(controller) { try { diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 2abbf1757e..be0442ae3a 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1,4 +1,3 @@ -import { enqueuePrivateStream } from "#veryfront/security/private-stream.ts"; /** * Agent Runtime - Core execution engine * @@ -14,6 +13,10 @@ import { enqueuePrivateStream } from "#veryfront/security/private-stream.ts"; import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { + createPrivateReadableStream, + enqueuePrivateStream, +} from "#veryfront/security/private-stream.ts"; import { createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { @@ -359,7 +362,6 @@ const cloneStructuredValue = globalThis.structuredClone; const IntrinsicWeakMap = WeakMap; const IntrinsicReflectApply = Reflect.apply; const IntrinsicStructuredClone = globalThis.structuredClone; -const IntrinsicReadableStream = ReadableStream; const PromiseThen = Promise.prototype.then; const ObjectCreate = Object.create; const ObjectDefineProperty = Object.defineProperty; @@ -2476,7 +2478,7 @@ export class AgentRuntime { const completion = createPrivateDeferred(); this.#onStreamCompletion?.(completion.promise); - const runtimeStream = new IntrinsicReadableStream({ + const runtimeStream = createPrivateReadableStream({ start: async (controller) => { try { throwIfAborted(streamAbortSignal); diff --git a/src/agent/streaming/tool-execution-data-event-bridge.ts b/src/agent/streaming/tool-execution-data-event-bridge.ts index 7a523b21f6..f3f5b1f9a3 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.ts @@ -1,10 +1,10 @@ import { closePrivateStream, + createPrivateReadableStream, enqueuePrivateStream, errorPrivateStream, - PrivateReadableStream, + getPrivateStreamReader, } from "#veryfront/security/private-stream.ts"; -import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { ToolExecutionDataEvent } from "#veryfront/tool/types.ts"; import { AGENT_ERROR } from "#veryfront/errors"; @@ -54,7 +54,7 @@ export function createToolExecutionDataEventBridgeStream( let baseReader: ReadableStreamDefaultReader | null = null; let closed = false; - return new PrivateReadableStream({ + return createPrivateReadableStream({ start(controller) { input.installPublisher((event) => { if (closed) { diff --git a/src/security/private-stream.test.ts b/src/security/private-stream.test.ts new file mode 100644 index 0000000000..273c13389a --- /dev/null +++ b/src/security/private-stream.test.ts @@ -0,0 +1,39 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createPrivateReadableStream } from "./private-stream.ts"; + +describe("private stream construction", () => { + it("preserves callback receivers while ignoring inherited source and strategy callbacks", async () => { + let inheritedReads = 0; + let receivers = 0; + const source = Object.create({ + get start() { + inheritedReads++; + return undefined; + }, + }, { + pull: { + value: function ( + this: UnderlyingDefaultSource, + controller: ReadableStreamDefaultController, + ) { + if (this === source) receivers++; + controller.enqueue("Synthetic output"); + controller.close(); + }, + enumerable: true, + }, + }) as UnderlyingDefaultSource; + const strategy = Object.create({ + get size() { + inheritedReads++; + return () => 1; + }, + }) as QueuingStrategy; + assertEquals(await Array.fromAsync(createPrivateReadableStream(source, strategy)), [ + "Synthetic output", + ]); + assertEquals(inheritedReads, 0); + assertEquals(receivers, 1); + }); +}); diff --git a/src/security/private-stream.ts b/src/security/private-stream.ts index e549fdcc1b..b3144dbfda 100644 --- a/src/security/private-stream.ts +++ b/src/security/private-stream.ts @@ -1,8 +1,22 @@ -import { observePrivatePromise } from "#veryfront/security/private-promise.ts"; +import { + chainPrivatePromise, + observePrivatePromise, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; const apply = Reflect.apply; const setPrototypeOf = Object.setPrototypeOf; const freeze = Object.freeze; +const ownDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; +const NativeReadableStream = ReadableStream; +const controllerEnqueue = ReadableStreamDefaultController.prototype.enqueue; +const controllerClose = ReadableStreamDefaultController.prototype.close; +const controllerError = ReadableStreamDefaultController.prototype.error; +const controllerDesiredSize = ownDescriptor( + ReadableStreamDefaultController.prototype, + "desiredSize", +)!.get!; const streamGetReader = ReadableStream.prototype.getReader; const streamCancel = ReadableStream.prototype.cancel; const streamLocked = Object.getOwnPropertyDescriptor(ReadableStream.prototype, "locked")!.get!; @@ -14,6 +28,76 @@ const readerClosed = Object.getOwnPropertyDescriptor( "closed", )!.get!; +function ownData(value: T, key: K): T[K] | undefined { + const descriptor = ownDescriptor(value, key); + if (!descriptor) return undefined; + if (!hasOwn(descriptor, "value")) { + throw new TypeError("Private stream options require data properties"); + } + return descriptor.value as T[K]; +} + +function privateController( + controller: ReadableStreamDefaultController, +): ReadableStreamDefaultController { + const facade: ReadableStreamDefaultController = { + enqueue: (chunk?: T) => { + apply(controllerEnqueue, controller, [chunk]); + }, + close: () => { + apply(controllerClose, controller, []); + }, + error: (reason?: unknown) => { + apply(controllerError, controller, [reason]); + }, + get desiredSize() { + return apply(controllerDesiredSize, controller, []) as number | null; + }, + }; + setPrototypeOf(facade, null); + return freeze(facade); +} + +/** Construct a default stream without exposing its source or controller through global hooks. */ +export function createPrivateReadableStream( + source: UnderlyingDefaultSource, + strategy?: QueuingStrategy, +): ReadableStream { + const start = ownData(source, "start"); + const pull = ownData(source, "pull"); + const cancel = ownData(source, "cancel"); + let controller: ReadableStreamDefaultController; + const invoke = (callback: unknown, args: unknown[]) => { + if (callback === undefined) return undefined; + if (typeof callback !== "function") { + throw new TypeError("Private stream callback must be a function"); + } + const result = apply(callback, source, args); + return result === undefined + ? undefined + : chainPrivatePromise(resolvePrivatePromise(), () => result); + }; + const privateSource = { + __proto__: null, + start(nativeController: ReadableStreamDefaultController) { + controller = privateController(nativeController); + return invoke(start, [controller]); + }, + pull() { + return invoke(pull, [controller]); + }, + cancel(reason: unknown) { + return invoke(cancel, [reason]); + }, + }; + const privateStrategy = { + __proto__: null, + highWaterMark: strategy === undefined ? undefined : ownData(strategy, "highWaterMark"), + size: strategy === undefined ? undefined : ownData(strategy, "size"), + }; + return new NativeReadableStream(privateSource, privateStrategy); +} + /** Keep private stream consumption independent of replaced Web Streams methods. */ export function getPrivateStreamReader( stream: ReadableStream, @@ -46,13 +130,7 @@ export function isPrivateStreamLocked(stream: ReadableStream): boolean return apply(streamLocked, stream, []) as boolean; } -const enqueue = ReadableStreamDefaultController.prototype.enqueue; -const close = ReadableStreamDefaultController.prototype.close; -const error = ReadableStreamDefaultController.prototype.error; -const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const hasOwn = Object.hasOwn; - -export const PrivateReadableStream = ReadableStream; +export const PrivateReadableStream = NativeReadableStream; type ControllerOperation = (...args: never[]) => unknown; @@ -62,20 +140,20 @@ function controllerMethod( native: ControllerOperation, ): ControllerOperation { // Internal forwarding controllers provide own methods; native controllers inherit theirs. - const descriptor = getOwnPropertyDescriptor(controller, key); + const descriptor = ownDescriptor(controller, key); return descriptor && hasOwn(descriptor, "value") && typeof descriptor.value === "function" ? descriptor.value : native; } export function enqueuePrivateStream(controller: ReadableStreamDefaultController, value: T) { - apply(controllerMethod(controller, "enqueue", enqueue), controller, [value]); + apply(controllerMethod(controller, "enqueue", controllerEnqueue), controller, [value]); } export function closePrivateStream(controller: ReadableStreamDefaultController) { - apply(controllerMethod(controller, "close", close), controller, []); + apply(controllerMethod(controller, "close", controllerClose), controller, []); } export function errorPrivateStream(controller: ReadableStreamDefaultController, reason: unknown) { - apply(controllerMethod(controller, "error", error), controller, [reason]); + apply(controllerMethod(controller, "error", controllerError), controller, [reason]); } diff --git a/tests/integration/agent/private-stream-intrinsics.test.ts b/tests/integration/agent/private-stream-intrinsics.test.ts index 61f91146b5..060a7fdee8 100644 --- a/tests/integration/agent/private-stream-intrinsics.test.ts +++ b/tests/integration/agent/private-stream-intrinsics.test.ts @@ -6,6 +6,57 @@ import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; describe("private stream intrinsics", () => { + it("forwards private chunks without invoking replaced constructors or controller methods", async () => { + const NativeReadableStream = ReadableStream; + const originalEnqueue = ReadableStreamDefaultController.prototype.enqueue; + const originalPull = Object.getOwnPropertyDescriptor(Object.prototype, "pull"); + const apply = Reflect.apply; + const construct = Reflect.construct; + const defineProperty = Object.defineProperty; + const ownDescriptor = Object.getOwnPropertyDescriptor; + const chunk = new TextEncoder().encode("Synthetic private forwarding chunk"); + const source = new NativeReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.close(); + }, + }); + let exposures = 0; + let chunks: Uint8Array[] = []; + try { + globalThis.ReadableStream = new Proxy(NativeReadableStream, { + construct(target, args, newTarget) { + exposures++; + return construct(target, args, newTarget); + }, + }); + ReadableStreamDefaultController.prototype.enqueue = function (value) { + if (value === chunk) exposures++; + return apply(originalEnqueue, this, [value]); + }; + defineProperty(Object.prototype, "pull", { + configurable: true, + get() { + if (typeof ownDescriptor(this, "start")?.value === "function") exposures++; + return undefined; + }, + }); + chunks = await Array.fromAsync( + createToolExecutionDataEventBridgeStream({ + baseStream: source, + installPublisher: () => {}, + }), + ); + } finally { + globalThis.ReadableStream = NativeReadableStream; + ReadableStreamDefaultController.prototype.enqueue = originalEnqueue; + if (originalPull) defineProperty(Object.prototype, "pull", originalPull); + else delete (Object.prototype as Record).pull; + } + assertEquals(chunks, [chunk]); + assertEquals(exposures, 0); + }); + it("keeps raw turn messages out of a replaced array mapper", async () => { const marker = "synthetic-private-turn-marker"; const originalMap = Array.prototype.map; From b6056dea129065e1efe7865225d0444ac291e392 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:20:40 +0200 Subject: [PATCH 110/194] fix(agent): protect private message encoding and byte validation --- src/agent/executor/protocol.ts | 5 +- src/agent/hosted/executor-agent-bridge.ts | 4 +- src/agent/hosted/executor-agent-schema.ts | 3 +- src/agent/runtime/index.ts | 24 +-- src/agent/runtime/sse-utils.ts | 6 +- src/agent/streaming/data-stream.ts | 3 +- src/agent/streaming/executor-data-stream.ts | 62 +++++--- src/agent/streaming/stream-events.ts | 5 +- .../tool-execution-data-event-bridge.ts | 19 +-- src/security/private-bytes.test.ts | 31 ++++ src/security/private-bytes.ts | 67 +++++++++ src/security/private-text.ts | 27 ++++ .../agent/executor-json-intrinsics.test.ts | 140 +++++++++++++----- .../agent/private-stream-intrinsics.test.ts | 10 +- 14 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 src/security/private-bytes.test.ts create mode 100644 src/security/private-bytes.ts create mode 100644 src/security/private-text.ts diff --git a/src/agent/executor/protocol.ts b/src/agent/executor/protocol.ts index 38462625ff..e6b31df867 100644 --- a/src/agent/executor/protocol.ts +++ b/src/agent/executor/protocol.ts @@ -1,3 +1,4 @@ +import { createPrivateTextDecoder, encodePrivateText } from "#veryfront/security/private-text.ts"; import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; @@ -74,7 +75,7 @@ export function encodeExecutorFrame(frame: ExecutorFrame): Uint8Array { if (!snapshot.success || !getExecutorFrameSchema().safeParse(snapshot.value).success) { throw new TypeError("Invalid executor frame"); } - const payload = new TextEncoder().encode(privateJsonStringify(snapshot.value)); + const payload = encodePrivateText(privateJsonStringify(snapshot.value)); if (payload.byteLength > EXECUTOR_MAX_FRAME_BYTES - 4) { throw new TypeError("Executor frame exceeds byte limit"); } @@ -95,7 +96,7 @@ export async function* readExecutorFrames( let prefixOffset = 0; let payload: Uint8Array | undefined; let payloadOffset = 0; - const decoder = new TextDecoder("utf-8", { fatal: true }); + const decoder = createPrivateTextDecoder("utf-8", { fatal: true }); while (true) { const { value: chunk, done } = await reader.read(); if (done) { diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 661bd9f593..59ccd735de 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -1,3 +1,4 @@ +import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { cancelPrivateStream, createPrivateReadableStream, @@ -26,7 +27,6 @@ import { parseExecutorAgentData, } from "./executor-agent-schema.ts"; -const textEncoder = new TextEncoder(); const MapConstructor = Map; const mapSet = Map.prototype.set; const apply = Reflect.apply; @@ -187,7 +187,7 @@ export function createExecutorHostedChatRuntimeAgent(options: { const event = parseExecutorDataEvent(frame.event); terminal ||= event.type === "message-finish" || event.type === "error"; controller.enqueue( - textEncoder.encode(`data: ${privateJsonStringify(event)}\n\n`), + encodePrivateText(`data: ${privateJsonStringify(event)}\n\n`), ); } else if (frame.type === "complete") { if (!terminal || !(await iterator.next()).done) { diff --git a/src/agent/hosted/executor-agent-schema.ts b/src/agent/hosted/executor-agent-schema.ts index 5bc9dc4a8b..a9b23ae666 100644 --- a/src/agent/hosted/executor-agent-schema.ts +++ b/src/agent/hosted/executor-agent-schema.ts @@ -1,3 +1,4 @@ +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema, type JsonValue } from "#veryfront/schemas/index.ts"; @@ -170,7 +171,7 @@ export function executorAgentJson(input: unknown, oversized: FailureCode): JsonV const encoded = privateJsonStringify(input); if ( encoded === undefined || - new TextEncoder().encode(encoded).byteLength > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + utf8ByteLength(encoded, EXECUTOR_AGENT_MAX_PAYLOAD_BYTES) > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES ) { throw new ExecutorAgentError(oversized); } diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index be0442ae3a..e66d8f07b4 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1,3 +1,8 @@ +import { + createPrivateTextDecoder, + encodePrivateText, + PrivateTextEncoder, +} from "#veryfront/security/private-text.ts"; /** * Agent Runtime - Core execution engine * @@ -948,7 +953,7 @@ type DeferredRecoveryOutput = | { kind: "callback"; chunk: string }; function isTextSseChunk(chunk: Uint8Array): boolean { - const payload = new TextDecoder().decode(chunk); + const payload = createPrivateTextDecoder().decode(chunk); if (!payload.startsWith("data: ")) { return false; } @@ -963,7 +968,7 @@ function isTextSseChunk(chunk: Uint8Array): boolean { } function isTextEndSseChunk(chunk: Uint8Array): boolean { - const payload = new TextDecoder().decode(chunk); + const payload = createPrivateTextDecoder().decode(chunk); if (!payload.startsWith("data: ")) { return false; } @@ -977,7 +982,7 @@ function isTextEndSseChunk(chunk: Uint8Array): boolean { } function textDeltaFromSseChunk(chunk: Uint8Array): string | undefined { - const payload = new TextDecoder().decode(chunk); + const payload = createPrivateTextDecoder().decode(chunk); if (!payload.startsWith("data: ")) { return undefined; } @@ -1006,7 +1011,7 @@ function stripTextDeltaPrefixFromSseChunk( remainingPrefixLength: number, encoder: TextEncoder, ): { chunk: Uint8Array | undefined; remainingPrefixLength: number } { - const payload = new TextDecoder().decode(chunk); + const payload = createPrivateTextDecoder().decode(chunk); if (!payload.startsWith("data: ")) { return { chunk, remainingPrefixLength }; } @@ -1021,8 +1026,9 @@ function stripTextDeltaPrefixFromSseChunk( return { chunk: undefined, remainingPrefixLength: stripped.remainingPrefixLength }; } return { - chunk: encoder.encode( + chunk: encodePrivateText( `data: ${privateJsonStringify({ ...event, delta: stripped.text })}\n\n`, + encoder, ), remainingPrefixLength: stripped.remainingPrefixLength, }; @@ -1036,7 +1042,7 @@ function rewriteRecoveryTextSseChunkId( fallbackId: string, encoder: TextEncoder, ): Uint8Array { - const payload = new TextDecoder().decode(chunk); + const payload = createPrivateTextDecoder().decode(chunk); if (!payload.startsWith("data: ")) { return chunk; } @@ -1052,7 +1058,7 @@ function rewriteRecoveryTextSseChunkId( const id = typeof event.id === "string" && event.id.length > 0 ? `${event.id}:recovery` : fallbackId; - return encoder.encode(`data: ${privateJsonStringify({ ...event, id })}\n\n`); + return encodePrivateText(`data: ${privateJsonStringify({ ...event, id })}\n\n`, encoder); } catch { return chunk; } @@ -1393,7 +1399,7 @@ function estimateSerializedSizeBytes(value: unknown): number | undefined { try { const serialized = typeof value === "string" ? value : privateJsonStringify(value); if (serialized === undefined) return undefined; - return new TextEncoder().encode(serialized).length; + return encodePrivateText(serialized).length; } catch { return undefined; } @@ -2401,7 +2407,7 @@ export class AgentRuntime { const systemPrompt = await this.resolveSystemPrompt(transport.providerOptionKey); - const encoder = new TextEncoder(); + const encoder = new PrivateTextEncoder(); const streamAbortSignal = abortScope.signal; const streamCacheCtx = tryGetCacheKeyContext(); const toolContext = { diff --git a/src/agent/runtime/sse-utils.ts b/src/agent/runtime/sse-utils.ts index 6ea479641a..b0e7499473 100644 --- a/src/agent/runtime/sse-utils.ts +++ b/src/agent/runtime/sse-utils.ts @@ -8,6 +8,7 @@ import { closePrivateStream, enqueuePrivateStream } from "#veryfront/security/pr */ import { privateJsonStringify } from "#veryfront/security/private-json.ts"; +import { encodePrivateText } from "#veryfront/security/private-text.ts"; // Runtime heuristic: detects a write to an already-closed ReadableStream controller. // Browser/Node and Deno use different messages for the same Web Streams state error. @@ -28,7 +29,10 @@ export function sendSSE( event: Record, ): void { try { - enqueuePrivateStream(controller, encoder.encode(`data: ${privateJsonStringify(event)}\n\n`)); + enqueuePrivateStream( + controller, + encodePrivateText(`data: ${privateJsonStringify(event)}\n\n`, encoder), + ); } catch (error) { if (isClosedStreamControllerError(error)) { return; diff --git a/src/agent/streaming/data-stream.ts b/src/agent/streaming/data-stream.ts index d613ad7c79..d2c8270621 100644 --- a/src/agent/streaming/data-stream.ts +++ b/src/agent/streaming/data-stream.ts @@ -1,3 +1,4 @@ +import { createPrivateTextDecoder } from "#veryfront/security/private-text.ts"; import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { serverLogger } from "#veryfront/utils"; @@ -52,7 +53,7 @@ export async function* streamDataStreamEvents( stream: ReadableStream, ): AsyncGenerator { const reader = getPrivateStreamReader(stream); - const decoder = new TextDecoder(); + const decoder = createPrivateTextDecoder(); let remainder = ""; let completed = false; diff --git a/src/agent/streaming/executor-data-stream.ts b/src/agent/streaming/executor-data-stream.ts index bc584c74e7..1fecd27c11 100644 --- a/src/agent/streaming/executor-data-stream.ts +++ b/src/agent/streaming/executor-data-stream.ts @@ -1,3 +1,11 @@ +import { createPrivateTextDecoder } from "#veryfront/security/private-text.ts"; +import { + isPrivateUint8Array, + privateByteLength, + privateByteSubarray, + PrivateUint8Array, + setPrivateBytes, +} from "#veryfront/security/private-bytes.ts"; import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { EXECUTOR_MAX_RETAINED_BYTES } from "../executor/protocol.ts"; @@ -7,14 +15,26 @@ import { } from "../hosted/executor-agent-schema.ts"; import { parseExecutorDataEvent } from "./executor-data-schema.ts"; +const apply = Reflect.apply; +const indexOf = String.prototype.indexOf; +const slice = String.prototype.slice; +const trimStart = String.prototype.trimStart; +const minimum = Math.min; + function parseBlock(block: string) { - const lines = block.split("\n"); - if (!lines.length || lines.some((line) => !line.startsWith("data:"))) { - throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + let payload = ""; + for (let offset = 0; offset <= block.length;) { + const newline = apply(indexOf, block, ["\n", offset]) as number; + const end = newline === -1 ? block.length : newline; + if (apply(slice, block, [offset, offset + 5]) !== "data:") { + throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); + } + if (offset > 0) payload += "\n"; + payload += apply(trimStart, apply(slice, block, [offset + 5, end]), []); + if (newline === -1) break; + offset = end + 1; } - return parseExecutorDataEvent( - privateJsonParse(lines.map((line) => line.slice(5).trimStart()).join("\n")), - ); + return parseExecutorDataEvent(privateJsonParse(payload)); } /** @internal Strict bounded SSE reader for the executor's runtime stream. */ @@ -23,9 +43,9 @@ export async function* readExecutorDataEvents( signal: AbortSignal, ) { const reader = getPrivateStreamReader(stream); - const validator = new TextDecoder("utf-8", { fatal: true }); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let pending = new Uint8Array(4096); + const validator = createPrivateTextDecoder("utf-8", { fatal: true }); + const decoder = createPrivateTextDecoder("utf-8", { fatal: true }); + let pending = new PrivateUint8Array(4096); let pendingBytes = 0; let terminal = false; let completed = false; @@ -46,29 +66,33 @@ export async function* readExecutorDataEvents( signal.throwIfAborted(); if (next.done) break; if ( - !(next.value instanceof Uint8Array) || next.value.byteLength > EXECUTOR_MAX_RETAINED_BYTES + !isPrivateUint8Array(next.value) || + privateByteLength(next.value) > EXECUTOR_MAX_RETAINED_BYTES ) { throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); } // Validate UTF-8 incrementally, including malformed streams that never end. // Frame raw bytes once; small chunks never rescan or re-encode a prefix. - for (let offset = 0; offset < next.value.byteLength; offset += 4096) { - const fragment = next.value.subarray(offset, offset + 4096); + for (let offset = 0; offset < privateByteLength(next.value); offset += 4096) { + const fragment = privateByteSubarray(next.value, offset, offset + 4096); validator.decode(fragment, { stream: true }); - for (const byte of fragment) { + for (let byteIndex = 0; byteIndex < privateByteLength(fragment); byteIndex++) { + const byte = fragment[byteIndex]!; if (terminal) throw new ExecutorAgentError("EXECUTOR_AGENT_INVALID_STREAM"); - if (pendingBytes === pending.length) { - const grown = new Uint8Array( - Math.min(pending.length * 2, EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + 2), + if (pendingBytes === privateByteLength(pending)) { + const grown = new PrivateUint8Array( + minimum(privateByteLength(pending) * 2, EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + 2), ); - grown.set(pending); + setPrivateBytes(grown, pending); pending = grown; } pending[pendingBytes++] = byte; if (byte === 10 && pendingBytes >= 2 && pending[pendingBytes - 2] === 10) { - const block = decoder.decode(pending.subarray(0, pendingBytes), { stream: true }); + const block = decoder.decode(privateByteSubarray(pending, 0, pendingBytes), { + stream: true, + }); pendingBytes = 0; - const event = parseBlock(block.slice(0, -2)); + const event = parseBlock(apply(slice, block, [0, -2]) as string); terminal ||= event.type === "message-finish" || event.type === "error"; yield event; } else if (pendingBytes > EXECUTOR_AGENT_MAX_PAYLOAD_BYTES + (byte === 10 ? 1 : 0)) { diff --git a/src/agent/streaming/stream-events.ts b/src/agent/streaming/stream-events.ts index 81460755ce..7c95c017d4 100644 --- a/src/agent/streaming/stream-events.ts +++ b/src/agent/streaming/stream-events.ts @@ -1,14 +1,13 @@ +import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { enqueuePrivateStream } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; export class StreamEventEmitter { - private encoder = new TextEncoder(); - constructor(private controller: ReadableStreamDefaultController) {} emit(event: Record): void { enqueuePrivateStream( this.controller, - this.encoder.encode(`data: ${privateJsonStringify(event)}\n\n`), + encodePrivateText(`data: ${privateJsonStringify(event)}\n\n`), ); } diff --git a/src/agent/streaming/tool-execution-data-event-bridge.ts b/src/agent/streaming/tool-execution-data-event-bridge.ts index f3f5b1f9a3..24e9978d97 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.ts @@ -1,3 +1,5 @@ +import { toPrivateUint8Array } from "#veryfront/security/private-bytes.ts"; +import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { closePrivateStream, createPrivateReadableStream, @@ -21,28 +23,19 @@ export type ToolExecutionDataEventBridgeStreamInput = { function serializeToolExecutionDataEvent(event: ToolExecutionDataEvent): Uint8Array { if (typeof event.name === "string" && event.name.length > 0) { const data = Object.hasOwn(event, "value") ? event.value : event.data; - return new TextEncoder().encode( + return encodePrivateText( `data: ${privateJsonStringify({ type: `data-${event.name}`, data })}\n\n`, ); } - return new TextEncoder().encode( + return encodePrivateText( `data: ${privateJsonStringify({ type: "data", data: event })}\n\n`, ); } function toUint8ArrayChunk(value: unknown): Uint8Array { - if (value instanceof Uint8Array) { - return value; - } - - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } - - if (value instanceof ArrayBuffer) { - return new Uint8Array(value); - } + const bytes = toPrivateUint8Array(value); + if (bytes !== undefined) return bytes; throw AGENT_ERROR.create({ detail: "Agent runtime returned a non-binary stream chunk" }); } diff --git a/src/security/private-bytes.test.ts b/src/security/private-bytes.test.ts new file mode 100644 index 0000000000..7eceac7673 --- /dev/null +++ b/src/security/private-bytes.test.ts @@ -0,0 +1,31 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { privateByteSubarray, toPrivateUint8Array } from "./private-bytes.ts"; + +describe("private binary views", () => { + it("keeps normalization within the supplied view's byte window", () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); + for ( + const view of [ + new Uint8Array(bytes.buffer, 2, 2), + new DataView(bytes.buffer, 2, 2), + new Uint16Array(bytes.buffer, 2, 1), + ] + ) { + assertEquals(toPrivateUint8Array(view), new Uint8Array([3, 4])); + } + assertEquals(toPrivateUint8Array(bytes.buffer), bytes); + assertEquals(toPrivateUint8Array("invalid"), undefined); + }); + + it("keeps subarrays within their source view while preserving relative bounds", () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); + const view = new Uint8Array(bytes.buffer, 1, 4); + assertEquals(privateByteSubarray(view, 1, 3), new Uint8Array([3, 4])); + assertEquals(privateByteSubarray(view, -2, -1), new Uint8Array([4])); + assertEquals(privateByteSubarray(view, -Infinity, Infinity), view); + assertEquals(privateByteSubarray(view, NaN, 1), new Uint8Array([2])); + assertEquals(privateByteSubarray(view, 3, 1), new Uint8Array()); + assertEquals(privateByteSubarray(view, 99), new Uint8Array()); + }); +}); diff --git a/src/security/private-bytes.ts b/src/security/private-bytes.ts new file mode 100644 index 0000000000..687210fc54 --- /dev/null +++ b/src/security/private-bytes.ts @@ -0,0 +1,67 @@ +const apply = Reflect.apply; +const NativeUint8Array = Uint8Array; +const NativeArrayBuffer = ArrayBuffer; +const NativeDataView = DataView; +const hasInstance = Function.prototype[Symbol.hasInstance]; +const isView = ArrayBuffer.isView; +const descriptor = Object.getOwnPropertyDescriptor; +const typedArrayPrototype = Object.getPrototypeOf(NativeUint8Array.prototype); +const typedBuffer = descriptor(typedArrayPrototype, "buffer")!.get!; +const typedOffset = descriptor(typedArrayPrototype, "byteOffset")!.get!; +const typedLength = descriptor(typedArrayPrototype, "byteLength")!.get!; +const viewBuffer = descriptor(NativeDataView.prototype, "buffer")!.get!; +const viewOffset = descriptor(NativeDataView.prototype, "byteOffset")!.get!; +const viewLength = descriptor(NativeDataView.prototype, "byteLength")!.get!; +const set = NativeUint8Array.prototype.set; +const minimum = Math.min; +const maximum = Math.max; +const truncate = Math.trunc; + +export const PrivateUint8Array = NativeUint8Array; + +export function isPrivateUint8Array(value: unknown): value is Uint8Array { + return apply(hasInstance, NativeUint8Array, [value]) as boolean; +} + +/** Normalize native binary views without calling project-controlled constructors or accessors. */ +export function toPrivateUint8Array(value: unknown): Uint8Array | undefined { + if (isPrivateUint8Array(value)) return value; + if (isView(value)) { + let dataView = false; + let buffer: ArrayBufferLike; + try { + buffer = apply(viewBuffer, value, []) as ArrayBufferLike; + dataView = true; + } catch { + buffer = apply(typedBuffer, value, []) as ArrayBufferLike; + } + const offset = apply(dataView ? viewOffset : typedOffset, value, []) as number; + const length = apply(dataView ? viewLength : typedLength, value, []) as number; + return new NativeUint8Array(buffer, offset, length); + } + if (apply(hasInstance, NativeArrayBuffer, [value])) { + return new NativeUint8Array(value as ArrayBuffer); + } + return undefined; +} + +export function privateByteLength(value: Uint8Array): number { + return apply(typedLength, value, []) as number; +} + +export function privateByteSubarray(value: Uint8Array, start: number, end?: number): Uint8Array { + const length = privateByteLength(value); + const index = (value: number): number => { + const integer = value === value ? truncate(value) : 0; + return integer < 0 ? maximum(length + integer, 0) : minimum(integer, length); + }; + const first = index(start); + const last = end === undefined ? length : index(end); + const buffer = apply(typedBuffer, value, []) as ArrayBufferLike; + const offset = apply(typedOffset, value, []) as number; + return new NativeUint8Array(buffer, offset + first, maximum(last - first, 0)); +} + +export function setPrivateBytes(target: Uint8Array, source: Uint8Array): void { + apply(set, target, [source]); +} diff --git a/src/security/private-text.ts b/src/security/private-text.ts new file mode 100644 index 0000000000..0badeea689 --- /dev/null +++ b/src/security/private-text.ts @@ -0,0 +1,27 @@ +const apply = Reflect.apply; +const NativeTextEncoder = TextEncoder; +const NativeTextDecoder = TextDecoder; +const encode = NativeTextEncoder.prototype.encode; +const decode = NativeTextDecoder.prototype.decode; +const setPrototypeOf = Object.setPrototypeOf; +const freeze = Object.freeze; +const encoder = new NativeTextEncoder(); + +export const PrivateTextEncoder = NativeTextEncoder; + +export function encodePrivateText(input?: string, target: TextEncoder = encoder): Uint8Array { + return apply(encode, target, [input]) as Uint8Array; +} + +export function createPrivateTextDecoder( + label?: string, + options?: TextDecoderOptions, +): Pick { + const decoder = new NativeTextDecoder(label, options); + const facade = { + decode: (input?: AllowSharedBufferSource, options?: TextDecodeOptions): string => + apply(decode, decoder, [input, options]) as string, + }; + setPrototypeOf(facade, null); + return freeze(facade); +} diff --git a/tests/integration/agent/executor-json-intrinsics.test.ts b/tests/integration/agent/executor-json-intrinsics.test.ts index 7fa56c7fb3..516fbe151f 100644 --- a/tests/integration/agent/executor-json-intrinsics.test.ts +++ b/tests/integration/agent/executor-json-intrinsics.test.ts @@ -3,49 +3,107 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { executorAgentJson } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { readExecutorDataEvents } from "#veryfront/agent/streaming/executor-data-stream.ts"; +import { createToolExecutionDataEventBridgeStream } from "#veryfront/agent/streaming/tool-execution-data-event-bridge.ts"; import { StreamEventEmitter } from "#veryfront/agent/streaming/stream-events.ts"; -describe("executor JSON intrinsics", () => { - it("keeps synthetic requests and model events out of replaced global JSON methods", async () => { - const marker = "synthetic-private-json-marker"; - const request = { messages: [{ text: marker }] }; - const originalParse = JSON.parse; - const originalStringify = JSON.stringify; - let observations = 0; - let snapshot: unknown; - const received: unknown[] = []; - const stream = new ReadableStream({ - start(controller) { - const emitter = new StreamEventEmitter(controller); - queueMicrotask(() => { - emitter.emitTextDelta("text-1", marker); - emitter.emitFinish(); - controller.close(); +describe("executor serialization intrinsics", () => { + for (const hook of ["JSON", "text encoding", "text decoding", "SSE mapping", "byte validation"]) { + it(`keeps synthetic requests and model events out of replaced ${hook} methods`, async () => { + const marker = "synthetic-private-json-marker"; + const request = { messages: [{ text: marker }] }; + const originalParse = JSON.parse; + const originalStringify = JSON.stringify; + const originalEncode = TextEncoder.prototype.encode; + const originalDecode = TextDecoder.prototype.decode; + const originalMap = Array.prototype.map; + const originalIsView = ArrayBuffer.isView; + const OriginalUint8Array = Uint8Array; + const hasInstance = Function.prototype[Symbol.hasInstance]; + const decoder = new TextDecoder(); + let observations = 0; + let snapshot: unknown; + const received: unknown[] = []; + const stream = new ReadableStream({ + start(controller) { + const emitter = new StreamEventEmitter(controller); + queueMicrotask(() => { + emitter.emitTextDelta("text-1", marker); + emitter.emitFinish(); + controller.close(); + }); + }, + }); + try { + if (hook === "JSON") { + JSON.stringify = ((value: unknown) => { + const encoded = originalStringify(value); + if (encoded?.includes(marker)) observations++; + return encoded; + }) as typeof JSON.stringify; + JSON.parse = ((text: string) => { + if (text.includes(marker)) observations++; + return originalParse(text); + }) as typeof JSON.parse; + } else if (hook === "text encoding") { + TextEncoder.prototype.encode = function (text = "") { + if (text.includes(marker)) observations++; + return Reflect.apply(originalEncode, this, [text]); + }; + } else if (hook === "text decoding") { + TextDecoder.prototype.decode = function (input, options) { + const text = Reflect.apply(originalDecode, this, [input, options]); + if (text.includes(marker)) observations++; + return text; + }; + } else if (hook === "SSE mapping") { + Array.prototype.map = function (callback, thisArg) { + for (let index = 0; index < this.length; index++) { + if (typeof this[index] === "string" && this[index].includes(marker)) observations++; + } + return Reflect.apply(originalMap, this, [callback, thisArg]); + }; + } else { + const observe = (value: unknown) => { + if ( + originalIsView(value) && + Reflect.apply(originalDecode, decoder, [value]).includes(marker) + ) { + observations++; + } + }; + ArrayBuffer.isView = (value: unknown): value is ArrayBufferView => { + observe(value); + return originalIsView(value); + }; + globalThis.Uint8Array = class extends OriginalUint8Array { + static override [Symbol.hasInstance](value: unknown): boolean { + observe(value); + return Reflect.apply(hasInstance, OriginalUint8Array, [value]); + } + } as Uint8ArrayConstructor; + } + snapshot = executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + const bridged = createToolExecutionDataEventBridgeStream({ + baseStream: stream, + installPublisher: () => {}, }); - }, - }); - try { - JSON.stringify = ((value: unknown) => { - const encoded = originalStringify(value); - if (encoded?.includes(marker)) observations++; - return encoded; - }) as typeof JSON.stringify; - JSON.parse = ((text: string) => { - if (text.includes(marker)) observations++; - return originalParse(text); - }) as typeof JSON.parse; - snapshot = executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); - for await (const event of readExecutorDataEvents(stream, new AbortController().signal)) { - received.push(event); + for await (const event of readExecutorDataEvents(bridged, new AbortController().signal)) { + received.push(event); + } + } finally { + JSON.stringify = originalStringify; + JSON.parse = originalParse; + TextEncoder.prototype.encode = originalEncode; + TextDecoder.prototype.decode = originalDecode; + Array.prototype.map = originalMap; + ArrayBuffer.isView = originalIsView; + globalThis.Uint8Array = OriginalUint8Array; } - } finally { - JSON.stringify = originalStringify; - JSON.parse = originalParse; - } - assertEquals(snapshot, request); - assertEquals(received, [{ type: "text-delta", id: "text-1", delta: marker }, { - type: "message-finish", - }]); - assertEquals(observations, 0); - }); + assertEquals(snapshot, request); + assertEquals(received, [{ type: "text-delta", id: "text-1", delta: marker }, { + type: "message-finish", + }]); + assertEquals(observations, 0); + }); + } }); diff --git a/tests/integration/agent/private-stream-intrinsics.test.ts b/tests/integration/agent/private-stream-intrinsics.test.ts index 060a7fdee8..abbb7acbb1 100644 --- a/tests/integration/agent/private-stream-intrinsics.test.ts +++ b/tests/integration/agent/private-stream-intrinsics.test.ts @@ -57,18 +57,23 @@ describe("private stream intrinsics", () => { assertEquals(exposures, 0); }); - it("keeps raw turn messages out of a replaced array mapper", async () => { + it("keeps raw turn messages and output out of replaced mapping and encoding methods", async () => { const marker = "synthetic-private-turn-marker"; const originalMap = Array.prototype.map; + const originalEncode = TextEncoder.prototype.encode; const apply = Reflect.apply; const isArray = Array.isArray; let exposures = 0; - const model = scriptedModel([{ text: "Synthetic answer" }]); + const model = scriptedModel([{ text: marker }]); const runtime = new AgentRuntime("private-messages", { model: "veryfront-cloud/openai/gpt-5.4", system: "Synthetic instructions", }, { resolveModelRuntime: () => model }); try { + TextEncoder.prototype.encode = function (text = "") { + if (text.includes(marker)) exposures++; + return apply(originalEncode, this, [text]); + }; Array.prototype.map = function (this: unknown[], ...args) { for (let index = 0; index < this.length; index++) { const message = this[index] as { parts?: unknown[] } | null; @@ -90,6 +95,7 @@ describe("private stream intrinsics", () => { ); } finally { Array.prototype.map = originalMap; + TextEncoder.prototype.encode = originalEncode; } assertEquals(model.calls.length, 1); assertEquals(exposures, 0); From f6a38d603b9b64ce41d2a464a0f65f523e7fa5a5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:28:53 +0200 Subject: [PATCH 111/194] fix(agent): retain private byte and decoder validation across runtime paths --- src/agent/executor/channel.ts | 11 +- src/agent/executor/protocol.ts | 45 ++++-- src/agent/hosted/executor-allocator-client.ts | 3 +- src/agent/hosted/executor-discovery-schema.ts | 4 +- src/agent/hosted/executor-session-schema.ts | 4 +- src/agent/runtime/ag-ui-contract.ts | 6 +- .../runtime/agent-invocation-contract.ts | 9 +- src/agent/runtime/builtin-skill-files.ts | 3 +- src/agent/runtime/index.ts | 3 +- src/agent/runtime/message-file-url-refresh.ts | 3 +- src/agent/runtime/project-files-client.ts | 14 +- src/agent/runtime/provider-replay.ts | 6 +- src/agent/runtime/tool-exposure.ts | 9 +- .../streaming/executor-data-stream.test.ts | 13 ++ .../tool-execution-data-event-bridge.test.ts | 26 ++++ src/security/private-bytes.test.ts | 30 +++- src/security/private-bytes.ts | 7 +- src/security/private-text.test.ts | 36 +++++ src/security/private-text.ts | 27 +++- .../agent/executor-json-intrinsics.test.ts | 132 ++++++++++++++++++ 20 files changed, 346 insertions(+), 45 deletions(-) create mode 100644 src/security/private-text.test.ts diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index d3c3219d88..71b34d966b 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -1,3 +1,5 @@ +import { encodePrivateText } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; @@ -663,7 +665,8 @@ class Channel implements ExecutorChannel { // Bound both queued bytes and control-frame bookkeeping, including the active write. if ( this.#writes.length >= this.#maxCalls * 4 + EXECUTOR_STREAM_WINDOW || - this.#queuedBytes + bytes.byteLength > EXECUTOR_STREAM_WINDOW * EXECUTOR_MAX_FRAME_BYTES + this.#queuedBytes + privateByteLength(bytes) > + EXECUTOR_STREAM_WINDOW * EXECUTOR_MAX_FRAME_BYTES ) { this.#fail("Executor write queue limit exceeded"); return Promise.reject(this.#error); @@ -671,7 +674,7 @@ class Channel implements ExecutorChannel { this.#sendSequence++; const done = Promise.withResolvers(); this.#writes.push({ bytes, done }); - this.#queuedBytes += bytes.byteLength; + this.#queuedBytes += privateByteLength(bytes); if (!this.#writing) void this.#flush(); return done.promise; } @@ -684,7 +687,7 @@ class Channel implements ExecutorChannel { await this.#writer.write(entry.bytes); if (this.#error) return; this.#writes.shift(); - this.#queuedBytes -= entry.bytes.byteLength; + this.#queuedBytes -= privateByteLength(entry.bytes); entry.done.resolve(); } } catch { @@ -702,7 +705,7 @@ class Channel implements ExecutorChannel { } #retainPayload(value: JsonValue): number { - const bytes = new TextEncoder().encode(privateJsonStringify(value)).byteLength; + const bytes = privateByteLength(encodePrivateText(privateJsonStringify(value))); if (this.#retainedBytes + bytes > this.#maxRetainedBytes) { this.#fail("Executor retained payload budget exceeded"); throw new ExecutorProtocolError("Executor retained payload budget exceeded"); diff --git a/src/agent/executor/protocol.ts b/src/agent/executor/protocol.ts index e6b31df867..ebb0cb4805 100644 --- a/src/agent/executor/protocol.ts +++ b/src/agent/executor/protocol.ts @@ -1,9 +1,18 @@ import { createPrivateTextDecoder, encodePrivateText } from "#veryfront/security/private-text.ts"; +import { + isPrivateUint8Array, + privateByteLength, + privateByteSubarray, + PrivateUint8Array, + setPrivateBytes, +} from "#veryfront/security/private-bytes.ts"; import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +const min = Math.min; + /** Internal protocol limits. The frame limit includes its four-byte length prefix. */ export const EXECUTOR_PROTOCOL_VERSION = 1; export const EXECUTOR_MAX_FRAME_BYTES = 1024 * 1024; @@ -76,12 +85,16 @@ export function encodeExecutorFrame(frame: ExecutorFrame): Uint8Array { throw new TypeError("Invalid executor frame"); } const payload = encodePrivateText(privateJsonStringify(snapshot.value)); - if (payload.byteLength > EXECUTOR_MAX_FRAME_BYTES - 4) { + const length = privateByteLength(payload); + if (length > EXECUTOR_MAX_FRAME_BYTES - 4) { throw new TypeError("Executor frame exceeds byte limit"); } - const bytes = new Uint8Array(payload.byteLength + 4); - new DataView(bytes.buffer).setUint32(0, payload.byteLength); - bytes.set(payload, 4); + const bytes = new PrivateUint8Array(length + 4); + bytes[0] = length >>> 24; + bytes[1] = length >>> 16; + bytes[2] = length >>> 8; + bytes[3] = length; + setPrivateBytes(bytes, payload, 4); return bytes; } @@ -92,7 +105,7 @@ export function encodeExecutorFrame(frame: ExecutorFrame): Uint8Array { export async function* readExecutorFrames( reader: ReadableStreamDefaultReader, ): AsyncGenerator { - const prefix = new Uint8Array(4); + const prefix = new PrivateUint8Array(4); let prefixOffset = 0; let payload: Uint8Array | undefined; let payloadOffset = 0; @@ -103,29 +116,33 @@ export async function* readExecutorFrames( if (prefixOffset || payload) throw new ExecutorProtocolError("Truncated executor frame"); return; } - if (!(chunk instanceof Uint8Array) || chunk.byteLength > EXECUTOR_MAX_FRAME_BYTES) { + if (!isPrivateUint8Array(chunk) || privateByteLength(chunk) > EXECUTOR_MAX_FRAME_BYTES) { throw new ExecutorProtocolError("Executor transport chunk exceeds byte limit"); } let offset = 0; - while (offset < chunk.byteLength) { + while (offset < privateByteLength(chunk)) { if (!payload) { - const size = Math.min(4 - prefixOffset, chunk.byteLength - offset); - prefix.set(chunk.subarray(offset, offset + size), prefixOffset); + const size = min(4 - prefixOffset, privateByteLength(chunk) - offset); + setPrivateBytes(prefix, privateByteSubarray(chunk, offset, offset + size), prefixOffset); offset += size; prefixOffset += size; if (prefixOffset < 4) continue; - const length = new DataView(prefix.buffer).getUint32(0); + const length = prefix[0]! * 0x1000000 + prefix[1]! * 0x10000 + prefix[2]! * 0x100 + + prefix[3]!; if (!length || length > EXECUTOR_MAX_FRAME_BYTES - 4) { throw new ExecutorProtocolError("Executor frame exceeds byte limit"); } - payload = new Uint8Array(length); + payload = new PrivateUint8Array(length); prefixOffset = 0; } - const size = Math.min(payload.byteLength - payloadOffset, chunk.byteLength - offset); - payload.set(chunk.subarray(offset, offset + size), payloadOffset); + const size = min( + privateByteLength(payload) - payloadOffset, + privateByteLength(chunk) - offset, + ); + setPrivateBytes(payload, privateByteSubarray(chunk, offset, offset + size), payloadOffset); payloadOffset += size; offset += size; - if (payloadOffset === payload.byteLength) { + if (payloadOffset === privateByteLength(payload)) { let decoded: unknown; try { decoded = privateJsonParse(decoder.decode(payload)); diff --git a/src/agent/hosted/executor-allocator-client.ts b/src/agent/hosted/executor-allocator-client.ts index 11cee1475f..4e188ef695 100644 --- a/src/agent/hosted/executor-allocator-client.ts +++ b/src/agent/hosted/executor-allocator-client.ts @@ -1,3 +1,4 @@ +import { createPrivateTextDecoder } from "#veryfront/security/private-text.ts"; import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { Buffer } from "node:buffer"; import { lookup } from "node:dns/promises"; @@ -141,7 +142,7 @@ export function createHostedExecutorAllocatorClient(options: { const body = Buffer.concat(chunks, size); try { responseValue = privateJsonParse( - new TextDecoder("utf-8", { fatal: true }).decode(body), + createPrivateTextDecoder("utf-8", { fatal: true }).decode(body), ); } finally { body.fill(0); diff --git a/src/agent/hosted/executor-discovery-schema.ts b/src/agent/hosted/executor-discovery-schema.ts index 5ede5317a8..52aaaaba05 100644 --- a/src/agent/hosted/executor-discovery-schema.ts +++ b/src/agent/hosted/executor-discovery-schema.ts @@ -1,3 +1,5 @@ +import { encodePrivateText } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { snapshotOwnDataRecords } from "#veryfront/security/own-data-record.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; @@ -110,7 +112,7 @@ export const getExecutorAgentDefinitionSchema = defineSchema((v) => { }).strict(), ).max(64).optional(), }).strict().refine((value) => - new TextEncoder().encode(privateJsonStringify(value)).byteLength <= + privateByteLength(encodePrivateText(privateJsonStringify(value))) <= EXECUTOR_DISCOVERY_MAX_DEFINITION_BYTES ); }); diff --git a/src/agent/hosted/executor-session-schema.ts b/src/agent/hosted/executor-session-schema.ts index 74794e5ea3..4bfa9422ce 100644 --- a/src/agent/hosted/executor-session-schema.ts +++ b/src/agent/hosted/executor-session-schema.ts @@ -1,3 +1,5 @@ +import { encodePrivateText } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { isIP } from "node:net"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; @@ -116,7 +118,7 @@ export function parseHostedExecutorData(schema: Schema, value: unknown): T const snapshot = snapshotBoundedJsonValue(value); if ( !snapshot.success || - new TextEncoder().encode(privateJsonStringify(snapshot.value)).byteLength > 32 * 1024 + privateByteLength(encodePrivateText(privateJsonStringify(snapshot.value))) > 32 * 1024 ) { throw new Error("Executor session invalid allocator data"); } diff --git a/src/agent/runtime/ag-ui-contract.ts b/src/agent/runtime/ag-ui-contract.ts index 6e873cb180..8041ef3997 100644 --- a/src/agent/runtime/ag-ui-contract.ts +++ b/src/agent/runtime/ag-ui-contract.ts @@ -1,3 +1,5 @@ +import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, SchemaValidator } from "#veryfront/extensions/schema/index.ts"; @@ -10,7 +12,7 @@ const MAX_CONTEXT_TOTAL_BYTES = 65_536; const MAX_FORWARDED_PROPS_BYTES = 196_608; const MAX_RUNTIME_MESSAGES = 100; -const encoder = new TextEncoder(); +const encoder = new PrivateTextEncoder(); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -18,7 +20,7 @@ function isRecord(value: unknown): value is Record { function isWithinJsonSizeLimit(value: unknown, maxBytes: number): boolean { try { - return encoder.encode(privateJsonStringify(value)).byteLength <= maxBytes; + return privateByteLength(encodePrivateText(privateJsonStringify(value), encoder)) <= maxBytes; } catch { return false; } diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index b81cfb4a70..934b108cfc 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -1,3 +1,5 @@ +import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, RefinementCtx } from "#veryfront/extensions/schema/index.ts"; @@ -14,22 +16,21 @@ const MAX_CONTEXT_TOTAL_BYTES = 65_536; const MAX_AGENT_CONFIG_BYTES = 65_536; const MAX_FORWARDED_PROPS_BYTES = 196_608; const MAX_CREDENTIAL_BYTES = MAX_RUNTIME_INFERENCE_CREDENTIAL_BYTES; -const encoder = new TextEncoder(); +const encoder = new PrivateTextEncoder(); const IntrinsicReflectApply = Reflect.apply; const RegExpPrototypeTest = RegExp.prototype.test; -const TextEncoderEncode = TextEncoder.prototype.encode; const INFERENCE_CREDENTIAL_PATTERN = /^[\x21-\x7e]+$/; function isWithinJsonSizeLimit(value: unknown, maxBytes: number): boolean { try { - return encoder.encode(privateJsonStringify(value)).byteLength <= maxBytes; + return privateByteLength(encodePrivateText(privateJsonStringify(value), encoder)) <= maxBytes; } catch { return false; } } function isWithinUtf8SizeLimit(value: string, maxBytes: number): boolean { - return (IntrinsicReflectApply(TextEncoderEncode, encoder, [value]) as Uint8Array).byteLength <= + return privateByteLength(encodePrivateText(value, encoder)) <= maxBytes; } diff --git a/src/agent/runtime/builtin-skill-files.ts b/src/agent/runtime/builtin-skill-files.ts index 4d47cbfaf9..26418efa10 100644 --- a/src/agent/runtime/builtin-skill-files.ts +++ b/src/agent/runtime/builtin-skill-files.ts @@ -1,3 +1,4 @@ +import { createPrivateTextDecoder } from "#veryfront/security/private-text.ts"; import { closeSync, constants, @@ -32,7 +33,7 @@ import { import type { SkillOperationBudget } from "#veryfront/skill/operation-budget.ts"; import { normalizeStrictRuntimeSkillReferencePath } from "./skill-metadata.ts"; -const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +const UTF8_DECODER = createPrivateTextDecoder("utf-8", { fatal: true }); const BUILTIN_SKILL_READABLE_DIR_SET = new Set(SKILL_READABLE_DIRS); const builtinFileSystem = createFileSystem(); diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index e66d8f07b4..1984ad3c1e 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1,3 +1,4 @@ +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; import { createPrivateTextDecoder, encodePrivateText, @@ -1399,7 +1400,7 @@ function estimateSerializedSizeBytes(value: unknown): number | undefined { try { const serialized = typeof value === "string" ? value : privateJsonStringify(value); if (serialized === undefined) return undefined; - return encodePrivateText(serialized).length; + return utf8ByteLength(serialized); } catch { return undefined; } diff --git a/src/agent/runtime/message-file-url-refresh.ts b/src/agent/runtime/message-file-url-refresh.ts index bc6d339a00..8b965c1c7c 100644 --- a/src/agent/runtime/message-file-url-refresh.ts +++ b/src/agent/runtime/message-file-url-refresh.ts @@ -1,3 +1,4 @@ +import { createPrivateTextDecoder } from "#veryfront/security/private-text.ts"; import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { type ChatUiMessage, @@ -332,7 +333,7 @@ async function readRuntimeTextFileContent( } const reader = response.body.getReader(); - const decoder = new TextDecoder(); + const decoder = createPrivateTextDecoder(); let content = ""; let shouldCancelReader = false; diff --git a/src/agent/runtime/project-files-client.ts b/src/agent/runtime/project-files-client.ts index 273980bd03..95cd3b0d02 100644 --- a/src/agent/runtime/project-files-client.ts +++ b/src/agent/runtime/project-files-client.ts @@ -1,3 +1,9 @@ +import { + createPrivateTextDecoder, + encodePrivateText, + PrivateTextEncoder, +} from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; @@ -70,8 +76,8 @@ const PUBLIC_PROJECT_FILE_LIST_PAGE_MAX_BYTES = const PUBLIC_PROJECT_FILE_RESPONSE_BLOCK_BYTES = 65_536; const PUBLIC_PROJECT_FILE_RESPONSE_YIELD_CHUNKS = 256; const PUBLIC_PROJECT_FILE_RESPONSE_MAX_CONSECUTIVE_EMPTY_CHUNKS = 4_096; -const publicProjectFileUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); -const publicProjectFileUtf8Encoder = new TextEncoder(); +const publicProjectFileUtf8Decoder = createPrivateTextDecoder("utf-8", { fatal: true }); +const publicProjectFileUtf8Encoder = new PrivateTextEncoder(); /** Whether a value is a canonical project-relative file path. */ export function isRuntimeProjectFilePath(path: unknown): path is string { @@ -485,7 +491,7 @@ async function readPublicJsonResponseWithinLimit( if (!response.body) { const text = await response.text(); throwIfRuntimeProjectFilesAborted(abortSignal); - const byteLength = publicProjectFileUtf8Encoder.encode(text).byteLength; + const byteLength = privateByteLength(encodePrivateText(text, publicProjectFileUtf8Encoder)); listingBudget?.consumeBytes(byteLength); if (byteLength > byteLimit) { throw new RangeError(`Project file response may contain at most ${byteLimit} bytes`); @@ -1809,7 +1815,7 @@ async function readBoundedResponseText( throwIfStrictProjectFilesRequestExpired(requestScope); try { - const text = new TextDecoder("utf-8", { fatal: true }).decode( + const text = createPrivateTextDecoder("utf-8", { fatal: true }).decode( bytes.subarray(0, byteLength), ); throwIfStrictProjectFilesRequestExpired(requestScope); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index f3f16f8818..557fe16747 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1,3 +1,5 @@ +import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; import { attachProviderMetadata, @@ -26,7 +28,7 @@ const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; const MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH = 256; -const UTF8_ENCODER = new TextEncoder(); +const UTF8_ENCODER = new PrivateTextEncoder(); const CHECKPOINT_KEYS = new Set([ "version", @@ -270,7 +272,7 @@ export function captureProviderReplayCheckpoint( // the mirror's event budget fails the run rather than silently dropping the // replay state. Monitor checkpoint sizes before enabling the host gate. if ( - UTF8_ENCODER.encode(stringifyChatJson(eventForSizeCheck)).byteLength > + privateByteLength(encodePrivateText(stringifyChatJson(eventForSizeCheck), UTF8_ENCODER)) > MAX_CONVERSATION_RUN_EVENT_PAYLOAD_BYTES ) { invalidCheckpoint("provider replay checkpoint event exceeds the durable event limit"); diff --git a/src/agent/runtime/tool-exposure.ts b/src/agent/runtime/tool-exposure.ts index b03ef89cd7..ba7fb2af8d 100644 --- a/src/agent/runtime/tool-exposure.ts +++ b/src/agent/runtime/tool-exposure.ts @@ -1,3 +1,5 @@ +import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; +import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import type { ToolDefinition } from "#veryfront/tool"; import { parseIntegrationToolIdentity } from "#veryfront/integrations/source-policy.ts"; import type { RuntimeToolLoadingMode } from "./runtime-tool-config.ts"; @@ -44,7 +46,7 @@ const TOOL_SEARCH_SCHEMA_MAX_NODES = 4_096; const TOOL_SEARCH_SCHEMA_MAX_BYTES = 65_536; const TOOL_SEARCH_TOTAL_SCHEMA_NODES = 65_536; const TOOL_SEARCH_TOTAL_SCHEMA_BYTES = 524_288; -const UTF8_ENCODER = new TextEncoder(); +const UTF8_ENCODER = new PrivateTextEncoder(); const ArrayIsArray = Array.isArray; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const ObjectGetPrototypeOf = Object.getPrototypeOf; @@ -173,7 +175,8 @@ function compareAscii(left: string, right: string): number { } function isUtf8LengthWithin(value: string, maxBytes: number): boolean { - return value.length <= maxBytes && UTF8_ENCODER.encode(value).byteLength <= maxBytes; + return value.length <= maxBytes && + privateByteLength(encodePrivateText(value, UTF8_ENCODER)) <= maxBytes; } /** Return whether a persisted name matches the existing non-empty tool id contract. */ @@ -208,7 +211,7 @@ function snapshotSchemaDescriptions( const debitBytes = (value: string): boolean => { if (value.length > TOOL_SEARCH_SCHEMA_MAX_BYTES) return false; - const length = UTF8_ENCODER.encode(value).byteLength; + const length = privateByteLength(encodePrivateText(value, UTF8_ENCODER)); bytes += length; aggregate.bytes += length; return bytes <= TOOL_SEARCH_SCHEMA_MAX_BYTES && diff --git a/src/agent/streaming/executor-data-stream.test.ts b/src/agent/streaming/executor-data-stream.test.ts index bc47653a05..9412a02fdb 100644 --- a/src/agent/streaming/executor-data-stream.test.ts +++ b/src/agent/streaming/executor-data-stream.test.ts @@ -23,6 +23,19 @@ async function collect(stream: ReadableStream) { } describe("executor runtime data stream validation", () => { + it("joins multiline data fields while retaining strict line prefixes", async () => { + const body = + 'data: {\ndata: "type": "text-delta",\ndata: "delta": "Synthetic multiline"}\n\ndata: {"type":"message-finish"}\n\n'; + assertEquals(await collect(new Response(body).body!), [ + { type: "text-delta", delta: "Synthetic multiline" }, + { type: "message-finish" }, + ]); + await assertRejects( + () => collect(new Response(body.replace('data: "delta"', 'event: "delta"')).body!), + ExecutorAgentError, + ); + }); + it("requires whole-message completion after a provider finishes its step", async () => { const step = 'data: {"type":"finish","finishReason":"tool-calls"}\n\n'; await assertRejects(() => collect(new Response(step).body!), ExecutorAgentError); diff --git a/src/agent/streaming/tool-execution-data-event-bridge.test.ts b/src/agent/streaming/tool-execution-data-event-bridge.test.ts index cea7725052..ea0874bb12 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.test.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.test.ts @@ -12,6 +12,32 @@ function requireStreamController( } describe("createToolExecutionDataEventBridgeStream", () => { + it("normalizes byte views without consulting overridden metadata getters", async () => { + const bytes = new Uint8Array([11, 22, 33, 44]); + const view = new DataView(bytes.buffer, 1, 2); + let reads = 0; + for (const key of ["buffer", "byteOffset", "byteLength"] as const) { + const value = view[key]; + Object.defineProperty(view, key, { + get() { + reads++; + return value; + }, + }); + } + const baseStream = new ReadableStream({ + start(controller) { + controller.enqueue(view); + controller.close(); + }, + }) as ReadableStream; + const chunks = await Array.fromAsync( + createToolExecutionDataEventBridgeStream({ baseStream, installPublisher: () => {} }), + ); + assertEquals(reads, 0); + assertEquals(chunks, [new Uint8Array([22, 33])]); + }); + it("does not expose its source through an overridden reader factory", async () => { let reads = 0; const baseStream = new ReadableStream({ diff --git a/src/security/private-bytes.test.ts b/src/security/private-bytes.test.ts index 7eceac7673..a343144ebb 100644 --- a/src/security/private-bytes.test.ts +++ b/src/security/private-bytes.test.ts @@ -1,8 +1,36 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { privateByteSubarray, toPrivateUint8Array } from "./private-bytes.ts"; +import { + privateByteLength, + privateByteSubarray, + PrivateUint8Array, + setPrivateBytes, + toPrivateUint8Array, +} from "./private-bytes.ts"; describe("private binary views", () => { + it("views and copies bytes without consulting metadata or species getters", () => { + const bytes = new Uint8Array([11, 22, 33, 44]); + let reads = 0; + for (const key of ["constructor", "buffer", "byteOffset", "byteLength"] as const) { + const value = bytes[key]; + Object.defineProperty(bytes, key, { + get() { + reads++; + return value; + }, + }); + } + const target = new PrivateUint8Array(3); + setPrivateBytes(target, privateByteSubarray(bytes, -3, -1), 1); + assertEquals(privateByteLength(bytes), 4); + assertEquals(target, new Uint8Array([0, 22, 33])); + assertEquals(reads, 0); + assertEquals(toPrivateUint8Array(new DataView(target.buffer, 1, 2)), new Uint8Array([22, 33])); + assertEquals(toPrivateUint8Array(target.buffer), target); + assertEquals(toPrivateUint8Array("invalid"), undefined); + }); + it("keeps normalization within the supplied view's byte window", () => { const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); for ( diff --git a/src/security/private-bytes.ts b/src/security/private-bytes.ts index 687210fc54..3f40d3addb 100644 --- a/src/security/private-bytes.ts +++ b/src/security/private-bytes.ts @@ -16,6 +16,7 @@ const set = NativeUint8Array.prototype.set; const minimum = Math.min; const maximum = Math.max; const truncate = Math.trunc; +const numberIsNaN = Number.isNaN; export const PrivateUint8Array = NativeUint8Array; @@ -52,7 +53,7 @@ export function privateByteLength(value: Uint8Array): number { export function privateByteSubarray(value: Uint8Array, start: number, end?: number): Uint8Array { const length = privateByteLength(value); const index = (value: number): number => { - const integer = value === value ? truncate(value) : 0; + const integer = numberIsNaN(value) ? 0 : truncate(value); return integer < 0 ? maximum(length + integer, 0) : minimum(integer, length); }; const first = index(start); @@ -62,6 +63,6 @@ export function privateByteSubarray(value: Uint8Array, start: number, end?: numb return new NativeUint8Array(buffer, offset + first, maximum(last - first, 0)); } -export function setPrivateBytes(target: Uint8Array, source: Uint8Array): void { - apply(set, target, [source]); +export function setPrivateBytes(target: Uint8Array, source: Uint8Array, offset = 0): void { + apply(set, target, [source, offset]); } diff --git a/src/security/private-text.test.ts b/src/security/private-text.test.ts new file mode 100644 index 0000000000..c5bf45745e --- /dev/null +++ b/src/security/private-text.test.ts @@ -0,0 +1,36 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createPrivateTextDecoder, encodePrivateText } from "./private-text.ts"; +import { privateByteSubarray } from "./private-bytes.ts"; +describe("private text codecs", () => { + it("does not inherit decoding flags that change UTF-8 validation", () => { + let reads = 0; + const options = Object.create({ + get fatal() { + reads++; + return true; + }, + }) as TextDecoderOptions; + const decoder = createPrivateTextDecoder("utf-8", options); + assertEquals(decoder.decode(new Uint8Array([0xff])), "\ufffd"); + const strict = createPrivateTextDecoder("utf-8", { fatal: true }); + const decodeOptions = Object.create({ + get stream() { + reads++; + return true; + }, + }) as TextDecodeOptions; + assertThrows(() => strict.decode(new Uint8Array([0xc3]), decodeOptions), TypeError); + assertEquals(reads, 0); + }); + + it("retains independent decoder state and UTF-8 validation", () => { + const bytes = encodePrivateText("å🙂"); + const first = createPrivateTextDecoder("utf-8", { fatal: true }); + const second = createPrivateTextDecoder("utf-8", { fatal: true }); + assertEquals(first.decode(privateByteSubarray(bytes, 0, 1), { stream: true }), ""); + assertEquals(second.decode(bytes), "å🙂"); + assertEquals(first.decode(privateByteSubarray(bytes, 1)), "å🙂"); + assertThrows(() => second.decode(new Uint8Array([0xff])), TypeError); + }); +}); diff --git a/src/security/private-text.ts b/src/security/private-text.ts index 0badeea689..aa74883429 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -5,8 +5,23 @@ const encode = NativeTextEncoder.prototype.encode; const decode = NativeTextDecoder.prototype.decode; const setPrototypeOf = Object.setPrototypeOf; const freeze = Object.freeze; +const ownDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; const encoder = new NativeTextEncoder(); +function ownOption( + value: T | undefined, + key: K, +): T[K] | undefined { + if (value === undefined) return undefined; + const descriptor = ownDescriptor(value, key); + if (!descriptor) return undefined; + if (!hasOwn(descriptor, "value")) { + throw new TypeError("Private decoder options require data properties"); + } + return descriptor.value as T[K]; +} + export const PrivateTextEncoder = NativeTextEncoder; export function encodePrivateText(input?: string, target: TextEncoder = encoder): Uint8Array { @@ -17,10 +32,18 @@ export function createPrivateTextDecoder( label?: string, options?: TextDecoderOptions, ): Pick { - const decoder = new NativeTextDecoder(label, options); + const decoderOptions = { + __proto__: null, + fatal: ownOption(options, "fatal"), + ignoreBOM: ownOption(options, "ignoreBOM"), + }; + const decoder = new NativeTextDecoder(label, decoderOptions); const facade = { decode: (input?: AllowSharedBufferSource, options?: TextDecodeOptions): string => - apply(decode, decoder, [input, options]) as string, + apply(decode, decoder, [input, { + __proto__: null, + stream: ownOption(options, "stream"), + }]) as string, }; setPrototypeOf(facade, null); return freeze(facade); diff --git a/tests/integration/agent/executor-json-intrinsics.test.ts b/tests/integration/agent/executor-json-intrinsics.test.ts index 516fbe151f..b9684ae161 100644 --- a/tests/integration/agent/executor-json-intrinsics.test.ts +++ b/tests/integration/agent/executor-json-intrinsics.test.ts @@ -7,6 +7,138 @@ import { createToolExecutionDataEventBridgeStream } from "#veryfront/agent/strea import { StreamEventEmitter } from "#veryfront/agent/streaming/stream-events.ts"; describe("executor serialization intrinsics", () => { + it("keeps private text out of replaced encoder and decoder operations", async () => { + const marker = "synthetic-private-codec-marker"; + const request = { messages: [{ text: marker }] }; + const NativeEncoder = TextEncoder; + const NativeDecoder = TextDecoder; + const encode = TextEncoder.prototype.encode; + const decode = TextDecoder.prototype.decode; + const apply = Reflect.apply; + const construct = Reflect.construct; + const payload = new NativeEncoder().encode( + `data: {"type":"text-delta","delta":"${marker}"}\n\ndata: {"type":"message-finish"}\n\n`, + ); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(payload); + controller.close(); + }, + }); + let exposures = 0; + const events: unknown[] = []; + let snapshot: unknown; + try { + globalThis.TextEncoder = new Proxy(NativeEncoder, { + construct(target, args, newTarget) { + exposures++; + return construct(target, args, newTarget); + }, + }); + globalThis.TextDecoder = new Proxy(NativeDecoder, { + construct(target, args, newTarget) { + exposures++; + return construct(target, args, newTarget); + }, + }); + NativeEncoder.prototype.encode = function (input) { + if (input?.includes(marker)) exposures++; + return apply(encode, this, [input]); + }; + NativeDecoder.prototype.decode = function (...args) { + const value = apply(decode, this, args) as string; + if (value.includes(marker)) exposures++; + return value; + }; + snapshot = executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + for await (const event of readExecutorDataEvents(stream, new AbortController().signal)) { + events.push(event); + } + } finally { + NativeEncoder.prototype.encode = encode; + NativeDecoder.prototype.decode = decode; + globalThis.TextEncoder = NativeEncoder; + globalThis.TextDecoder = NativeDecoder; + } + assertEquals(snapshot, request); + assertEquals(events, [{ type: "text-delta", delta: marker }, { type: "message-finish" }]); + assertEquals(exposures, 0); + }); + + it("does not expose decoded SSE lines to a replaced array mapper", async () => { + const marker = "synthetic-private-line-marker"; + const originalMap = Array.prototype.map; + const apply = Reflect.apply; + const body = new Response( + `data: {\ndata: "type":"text-delta",\ndata: "delta":"${marker}"}\n\ndata: {"type":"message-finish"}\n\n`, + ).body!; + let exposures = 0; + const events: unknown[] = []; + try { + Array.prototype.map = function (this: unknown[], ...args) { + for (let index = 0; index < this.length; index++) { + const line = this[index]; + if (typeof line === "string" && line.includes(marker)) exposures++; + } + return apply(originalMap, this, args); + } as typeof originalMap; + for await (const event of readExecutorDataEvents(body, new AbortController().signal)) { + events.push(event); + } + } finally { + Array.prototype.map = originalMap; + } + assertEquals(events, [{ type: "text-delta", delta: marker }, { type: "message-finish" }]); + assertEquals(exposures, 0); + }); + + it("checks binary output without invoking replaced typed-array brands", async () => { + const NativeBytes = Uint8Array; + const isView = ArrayBuffer.isView; + const hasInstance = Function.prototype[Symbol.hasInstance]; + const apply = Reflect.apply; + const get = Reflect.get; + const bytes = new NativeBytes([11, 22, 33]); + const view = new DataView(bytes.buffer, 1, 2); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.enqueue(view); + controller.close(); + }, + }) as ReadableStream; + let exposures = 0; + let chunks: Uint8Array[] = []; + try { + globalThis.Uint8Array = new Proxy(NativeBytes, { + get(target, key, receiver) { + if (key === Symbol.hasInstance) { + return (value: unknown) => { + exposures++; + return apply(hasInstance, target, [value]); + }; + } + return get(target, key, receiver); + }, + }); + ArrayBuffer.isView = ((value: unknown) => { + exposures++; + return isView(value); + }) as typeof isView; + chunks = await Array.fromAsync( + createToolExecutionDataEventBridgeStream({ + baseStream: source, + installPublisher: () => {}, + }), + ); + } finally { + globalThis.Uint8Array = NativeBytes; + ArrayBuffer.isView = isView; + } + assertEquals(chunks, [bytes, new NativeBytes([22, 33])]); + assertEquals(exposures, 0); + }); + for (const hook of ["JSON", "text encoding", "text decoding", "SSE mapping", "byte validation"]) { it(`keeps synthetic requests and model events out of replaced ${hook} methods`, async () => { const marker = "synthetic-private-json-marker"; From c37833c9a683d75ae58e20dbba78f4b9040af68c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:34:42 +0200 Subject: [PATCH 112/194] fix(agent): keep channel dispatch and JSON serialization private --- src/agent/executor/channel.ts | 18 +++- src/security/private-json.test.ts | 41 +++++++ src/security/private-json.ts | 55 +++++++++- .../agent/executor-channel-intrinsics.test.ts | 101 ++++++++++++++++++ .../agent/executor-json-intrinsics.test.ts | 22 +++- 5 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 src/security/private-json.test.ts create mode 100644 tests/integration/agent/executor-channel-intrinsics.test.ts diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index 71b34d966b..98df09cd5a 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -1,5 +1,6 @@ import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; +import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; @@ -19,6 +20,13 @@ import { readExecutorFrames, } from "./protocol.ts"; +const apply = Reflect.apply; +const mapGet = Map.prototype.get; + +function privateMapGet(map: ReadonlyMap, key: K): V | undefined { + return apply(mapGet, map, [key]) as V | undefined; +} + /** A connection authenticated by its owner before channel construction. No reconnect or replay. */ export interface ExecutorByteTransport { readable: ReadableStream; @@ -185,7 +193,7 @@ class Channel implements ExecutorChannel { ); const handshakeTimeout = positiveBound(options.handshakeTimeoutMs ?? 5_000, 60_000); this.#operations = new Map(options.operations); - this.#reader = options.transport.readable.getReader(); + this.#reader = getPrivateStreamReader(options.transport.readable); this.#writer = options.transport.writable.getWriter(); this.#handshakeTimer = setTimeout( () => this.#fail("Executor handshake deadline exceeded"), @@ -466,7 +474,7 @@ class Channel implements ExecutorChannel { return; } if (message.type === "released") { - const call = this.#outgoingById.get(message.id); + const call = privateMapGet(this.#outgoingById, message.id); if (!call?.releaseAck || !call.ended) { throw new ExecutorProtocolError("Executor unknown release acknowledgement"); } @@ -475,7 +483,7 @@ class Channel implements ExecutorChannel { return; } if (message.type === "data" || message.type === "end") { - const call = this.#outgoingById.get(message.id); + const call = privateMapGet(this.#outgoingById, message.id); if (!call || call.ended || call.released) { throw new ExecutorProtocolError("Executor unknown or completed response"); } @@ -504,7 +512,7 @@ class Channel implements ExecutorChannel { call.wake?.(); return; } - const call = this.#incoming.get(message.id); + const call = privateMapGet(this.#incoming, message.id); if (!call || call.released) throw new ExecutorProtocolError("Executor unknown call control"); if (message.type === "credit") { if ( @@ -566,7 +574,7 @@ class Channel implements ExecutorChannel { ): Promise { let iterator: AsyncIterator | undefined; try { - const operation = this.#operations.get(message.operation); + const operation = privateMapGet(this.#operations, message.operation); if (!operation) { await this.#end(call, "operation-not-found"); return; diff --git a/src/security/private-json.test.ts b/src/security/private-json.test.ts new file mode 100644 index 0000000000..6ebe07056c --- /dev/null +++ b/src/security/private-json.test.ts @@ -0,0 +1,41 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { privateJsonStringify } from "./private-json.ts"; + +describe("private JSON serialization", () => { + it("preserves JSON data, omission rules, sparse arrays, and indentation", () => { + const value = { text: "hello", nested: [1, , undefined, null], absent: undefined }; + assertEquals(privateJsonStringify(value), JSON.stringify(value)); + assertEquals(privateJsonStringify(value, null, 2), JSON.stringify(value, null, 2)); + assertEquals(privateJsonStringify(undefined), undefined); + const shared = { value: 1 }; + assertEquals(privateJsonStringify([shared, shared]), '[{"value":1},{"value":1}]'); + }); + + it("does not dispatch own serialization hooks or property accessors", () => { + let observations = 0; + const value = { + text: "synthetic-private-json-marker", + toJSON() { + observations++; + return this; + }, + }; + assertEquals(privateJsonStringify(value), '{"text":"synthetic-private-json-marker"}'); + assertThrows(() => + privateJsonStringify({ + get text() { + observations++; + return "private"; + }, + }), TypeError); + assertEquals(observations, 0); + }); + + it("rejects cycles and bigint values", () => { + const value: { self?: unknown } = {}; + value.self = value; + assertThrows(() => privateJsonStringify(value), TypeError); + assertThrows(() => privateJsonStringify(1n), TypeError); + }); +}); diff --git a/src/security/private-json.ts b/src/security/private-json.ts index 872b488b26..ca0b6964d0 100644 --- a/src/security/private-json.ts +++ b/src/security/private-json.ts @@ -1,3 +1,56 @@ /** JSON operations captured before project discovery can replace global methods. */ export const privateJsonParse = JSON.parse; -export const privateJsonStringify = JSON.stringify; +const stringify = JSON.stringify; +const apply = Reflect.apply; +const ownKeys = Reflect.ownKeys; +const descriptor = Object.getOwnPropertyDescriptor; +const defineProperty = Object.defineProperty; +const setPrototypeOf = Object.setPrototypeOf; +const hasOwn = Object.hasOwn; +const isArray = Array.isArray; +const NativeSet = Set; +const setHas = Set.prototype.has; +const setAdd = Set.prototype.add; +const setDelete = Set.prototype.delete; + +/** Serialize own data without invoking accessors or inherited or own toJSON methods. */ +export function privateJsonStringify( + value: unknown, + _replacer: null = null, + space?: string | number, +) { + const ancestors = new NativeSet(); + const copy = (input: unknown): unknown => { + if (typeof input === "function") return undefined; + if (typeof input === "bigint") throw new TypeError("Cannot serialize bigint data"); + if (input === null || typeof input !== "object") return input; + if (apply(setHas, ancestors, [input])) throw new TypeError("Cannot serialize circular data"); + apply(setAdd, ancestors, [input]); + try { + const output = isArray(input) ? [] : {}; + setPrototypeOf(output, null); + const keys = ownKeys(input); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + if (typeof key !== "string") continue; + const property = descriptor(input, key); + if (!property || (!property.enumerable && !(isArray(input) && key === "length"))) continue; + if (!hasOwn(property, "value")) { + throw new TypeError("Private JSON requires data properties"); + } + const copiedProperty = { + __proto__: null, + value: copy(property.value), + enumerable: property.enumerable, + writable: true, + configurable: key !== "length" || !isArray(input), + }; + defineProperty(output, key, copiedProperty); + } + return output; + } finally { + apply(setDelete, ancestors, [input]); + } + }; + return stringify(copy(value), null, space); +} diff --git a/tests/integration/agent/executor-channel-intrinsics.test.ts b/tests/integration/agent/executor-channel-intrinsics.test.ts new file mode 100644 index 0000000000..dc9af3e9b3 --- /dev/null +++ b/tests/integration/agent/executor-channel-intrinsics.test.ts @@ -0,0 +1,101 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createExecutorChannel, + type ExecutorOperation, + type ExecutorOperationContext, +} from "#veryfront/agent/executor/channel.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; + +describe("private executor channel dispatch", () => { + for (const hook of ["operation lookup", "text codecs", "byte copies", "transport reads"]) { + it(`keeps request payloads out of replaced ${hook}`, async () => { + const binding = { allocationId: "channel", invocationId: "channel", generation: 1 }; + const marker = "synthetic-private-channel-message"; + const forward = new TransformStream(); + const backward = new TransformStream(); + const caller = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const receiver = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: new Map([["agent.stream", { + mode: "stream", + async *handle(input, context) { + assertEquals(context.binding, binding); + yield input; + }, + }]]), + }); + const originalSet = Uint8Array.prototype.set; + const originalRead = ReadableStreamDefaultReader.prototype.read; + const decoder = new TextDecoder(); + const originalGet = Map.prototype.get; + const originalEncode = TextEncoder.prototype.encode; + const originalDecode = TextDecoder.prototype.decode; + let observations = 0; + let received: JsonValue[] = []; + try { + await Promise.all([caller.ready, receiver.ready]); + if (hook === "operation lookup") { + Map.prototype.get = function (key) { + const operation = Reflect.apply(originalGet, this, [key]); + if (key === "agent.stream" && operation?.mode === "stream") { + return { + ...operation, + handle(input: JsonValue, context: ExecutorOperationContext) { + observations++; + return Reflect.apply(operation.handle, operation, [input, context]); + }, + }; + } + return operation; + }; + } else if (hook === "byte copies") { + Uint8Array.prototype.set = function (source, offset) { + if ( + source instanceof Uint8Array && + Reflect.apply(originalDecode, decoder, [source]).includes(marker) + ) observations++; + return Reflect.apply(originalSet, this, [source, offset]); + }; + } else if (hook === "transport reads") { + ReadableStreamDefaultReader.prototype.read = async function () { + const result = await Reflect.apply(originalRead, this, []); + if ( + result.value instanceof Uint8Array && + Reflect.apply(originalDecode, decoder, [result.value]).includes(marker) + ) observations++; + return result; + }; + } else { + TextEncoder.prototype.encode = function (text = "") { + if (text.includes(marker)) observations++; + return Reflect.apply(originalEncode, this, [text]); + }; + TextDecoder.prototype.decode = function (input, options) { + const text = Reflect.apply(originalDecode, this, [input, options]); + if (text.includes(marker)) observations++; + return text; + }; + } + received = await Array.fromAsync(caller.stream("agent.stream", { text: marker })); + received.push(...await Array.fromAsync(caller.stream("agent.stream", { text: marker }))); + } finally { + Uint8Array.prototype.set = originalSet; + ReadableStreamDefaultReader.prototype.read = originalRead; + Map.prototype.get = originalGet; + TextEncoder.prototype.encode = originalEncode; + TextDecoder.prototype.decode = originalDecode; + caller.close(); + receiver.close(); + await Promise.all([caller.settled, receiver.settled]); + } + assertEquals(received, [{ text: marker }, { text: marker }]); + assertEquals(observations, 0); + }); + } +}); diff --git a/tests/integration/agent/executor-json-intrinsics.test.ts b/tests/integration/agent/executor-json-intrinsics.test.ts index b9684ae161..c322f303df 100644 --- a/tests/integration/agent/executor-json-intrinsics.test.ts +++ b/tests/integration/agent/executor-json-intrinsics.test.ts @@ -139,10 +139,20 @@ describe("executor serialization intrinsics", () => { assertEquals(exposures, 0); }); - for (const hook of ["JSON", "text encoding", "text decoding", "SSE mapping", "byte validation"]) { + for ( + const hook of [ + "JSON", + "inherited toJSON", + "text encoding", + "text decoding", + "SSE mapping", + "byte validation", + ] + ) { it(`keeps synthetic requests and model events out of replaced ${hook} methods`, async () => { const marker = "synthetic-private-json-marker"; const request = { messages: [{ text: marker }] }; + const originalToJson = Object.getOwnPropertyDescriptor(Object.prototype, "toJSON"); const originalParse = JSON.parse; const originalStringify = JSON.stringify; const originalEncode = TextEncoder.prototype.encode; @@ -176,6 +186,14 @@ describe("executor serialization intrinsics", () => { if (text.includes(marker)) observations++; return originalParse(text); }) as typeof JSON.parse; + } else if (hook === "inherited toJSON") { + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value() { + observations++; + return this; + }, + }); } else if (hook === "text encoding") { TextEncoder.prototype.encode = function (text = "") { if (text.includes(marker)) observations++; @@ -223,6 +241,8 @@ describe("executor serialization intrinsics", () => { received.push(event); } } finally { + if (originalToJson) Object.defineProperty(Object.prototype, "toJSON", originalToJson); + else Reflect.deleteProperty(Object.prototype, "toJSON"); JSON.stringify = originalStringify; JSON.parse = originalParse; TextEncoder.prototype.encode = originalEncode; From 5adb5660e30f0aa06b605f00901f092a6a8ae7b3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:38:29 +0200 Subject: [PATCH 113/194] fix(agent): protect private transport writes and channel queues --- src/agent/executor/channel.ts | 72 ++++++++++++++----- src/security/private-stream.ts | 22 ++++++ .../agent/executor-channel-intrinsics.test.ts | 36 +++++++++- 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index 98df09cd5a..d0c5d634a6 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -1,6 +1,11 @@ import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; -import { getPrivateStreamReader } from "#veryfront/security/private-stream.ts"; +import { + getPrivateStreamReader, + getPrivateStreamWriter, +} from "#veryfront/security/private-stream.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; @@ -22,6 +27,28 @@ import { const apply = Reflect.apply; const mapGet = Map.prototype.get; +const mapSet = Map.prototype.set; +const mapDelete = Map.prototype.delete; +const mapClear = Map.prototype.clear; +const mapForEach = Map.prototype.forEach; +const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!; +const setClear = Set.prototype.clear; + +function appendPrivateQueue(queue: T[], value: T): void { + defineOwnDataProperty(queue, queue.length, value, { + enumerable: true, + configurable: true, + writable: true, + }); +} + +function shiftPrivateQueue(queue: T[]): T | undefined { + if (!queue.length) return undefined; + const first = queue[0]; + for (let index = 1; index < queue.length; index++) queue[index - 1] = queue[index]!; + queue.length--; + return first; +} function privateMapGet(map: ReadonlyMap, key: K): V | undefined { return apply(mapGet, map, [key]) as V | undefined; @@ -153,7 +180,10 @@ export function createExecutorChannel(options: ExecutorChannelOptions): Executor class Channel implements ExecutorChannel { readonly #binding: Readonly; readonly #reader: ReadableStreamDefaultReader; - readonly #writer: WritableStreamDefaultWriter; + readonly #writer: Pick< + WritableStreamDefaultWriter, + "write" | "abort" | "releaseLock" + >; readonly #operations: ReadonlyMap; readonly #maxCalls: number; readonly #timeout: number; @@ -164,7 +194,7 @@ class Channel implements ExecutorChannel { readonly #ready = Promise.withResolvers(); readonly #closed = Promise.withResolvers(); readonly #settled = Promise.withResolvers(); - readonly #outgoing = new Set(); + readonly #outgoing = createPrivateSet(); readonly #outgoingById = new Map(); readonly #incoming = new Map(); readonly #writes: { bytes: Uint8Array; done: Deferred }[] = []; @@ -194,7 +224,7 @@ class Channel implements ExecutorChannel { const handshakeTimeout = positiveBound(options.handshakeTimeoutMs ?? 5_000, 60_000); this.#operations = new Map(options.operations); this.#reader = getPrivateStreamReader(options.transport.readable); - this.#writer = options.transport.writable.getWriter(); + this.#writer = getPrivateStreamWriter(options.transport.writable); this.#handshakeTimer = setTimeout( () => this.#fail("Executor handshake deadline exceeded"), handshakeTimeout, @@ -337,7 +367,7 @@ class Channel implements ExecutorChannel { return; } call.id = ++this.#nextId; - this.#outgoingById.set(call.id, call); + apply(mapSet, this.#outgoingById, [call.id, call]); await this.#send({ ...message, id: call.id, timeoutMs: remaining }); } catch { if (!this.#error) this.#fail("Executor channel write failed"); @@ -360,7 +390,7 @@ class Channel implements ExecutorChannel { await this.#releaseOutgoing(call); return { done: true, value: undefined }; } - const { value, bytes } = call.queue.shift()!; + const { value, bytes } = shiftPrivateQueue(call.queue)!; if (call.mode === "unary") call.unaryBytes = bytes; try { call.consumed++; @@ -423,7 +453,7 @@ class Channel implements ExecutorChannel { } finally { this.#clearCallTimers(call); this.#outgoing.delete(call); - this.#outgoingById.delete(call.id); + apply(mapDelete, this.#outgoingById, [call.id]); } })(); return call.release; @@ -496,7 +526,7 @@ class Channel implements ExecutorChannel { call.received++; if (!call.cancelled) { const bytes = this.#retainPayload(message.value); - call.queue.push({ value: message.value, bytes }); + appendPrivateQueue(call.queue, { value: message.value, bytes }); } } else { call.ended = true; @@ -538,7 +568,7 @@ class Channel implements ExecutorChannel { if (message.id <= this.#lastReceivedId) { throw new ExecutorProtocolError("Executor request sequence violation"); } - if (this.#incoming.size >= this.#maxCalls) { + if (apply(mapSize, this.#incoming, []) >= this.#maxCalls) { throw new ExecutorProtocolError("Executor concurrent request limit exceeded"); } this.#lastReceivedId = message.id; @@ -556,7 +586,7 @@ class Channel implements ExecutorChannel { cancelled: false, settled: false, }; - this.#incoming.set(call.id, call); + apply(mapSet, this.#incoming, [call.id, call]); call.timer = setTimeout(() => this.#abortIncoming(call, "deadline"), timeout); this.#activeHandlers++; void this.#run(call, message, call.deadline).catch(() => { @@ -653,7 +683,7 @@ class Channel implements ExecutorChannel { if (call.released && call.settled) { clearTimeout(call.timer); clearTimeout(call.cancellationTimer); - this.#incoming.delete(call.id); + apply(mapDelete, this.#incoming, [call.id]); if (!this.#error) this.#control({ type: "released", id: call.id }); } } @@ -681,7 +711,7 @@ class Channel implements ExecutorChannel { } this.#sendSequence++; const done = Promise.withResolvers(); - this.#writes.push({ bytes, done }); + appendPrivateQueue(this.#writes, { bytes, done }); this.#queuedBytes += privateByteLength(bytes); if (!this.#writing) void this.#flush(); return done.promise; @@ -694,7 +724,7 @@ class Channel implements ExecutorChannel { const entry = this.#writes[0]!; await this.#writer.write(entry.bytes); if (this.#error) return; - this.#writes.shift(); + shiftPrivateQueue(this.#writes); this.#queuedBytes -= privateByteLength(entry.bytes); entry.done.resolve(); } @@ -723,7 +753,9 @@ class Channel implements ExecutorChannel { } #clearResults(call: OutgoingCall): void { - for (const result of call.queue) this.#retainedBytes -= result.bytes; + for (let index = 0; index < call.queue.length; index++) { + this.#retainedBytes -= call.queue[index]!.bytes; + } call.queue.length = 0; } @@ -748,16 +780,18 @@ class Channel implements ExecutorChannel { call.completion.reject(error); call.wake?.(); } - for (const call of this.#incoming.values()) { + apply(mapForEach, this.#incoming, [(call: IncomingCall) => { clearTimeout(call.timer); clearTimeout(call.cancellationTimer); call.controller.abort(error); call.wake?.(); + }]); + apply(setClear, this.#outgoing, []); + apply(mapClear, this.#outgoingById, []); + apply(mapClear, this.#incoming, []); + for (let index = 0; index < this.#writes.length; index++) { + this.#writes[index]!.done.reject(error); } - this.#outgoing.clear(); - this.#outgoingById.clear(); - this.#incoming.clear(); - for (const entry of this.#writes) entry.done.reject(error); this.#writes.length = 0; this.#queuedBytes = 0; void this.#reader.cancel(error).catch(() => {}).finally(() => { diff --git a/src/security/private-stream.ts b/src/security/private-stream.ts index b3144dbfda..056a30e5d5 100644 --- a/src/security/private-stream.ts +++ b/src/security/private-stream.ts @@ -27,6 +27,28 @@ const readerClosed = Object.getOwnPropertyDescriptor( ReadableStreamDefaultReader.prototype, "closed", )!.get!; +const streamGetWriter = WritableStream.prototype.getWriter; +const writerWrite = WritableStreamDefaultWriter.prototype.write; +const writerAbort = WritableStreamDefaultWriter.prototype.abort; +const writerReleaseLock = WritableStreamDefaultWriter.prototype.releaseLock; + +/** Keep private transport writes and cleanup independent of replaced stream methods. */ +export function getPrivateStreamWriter( + stream: WritableStream, +): Pick, "write" | "abort" | "releaseLock"> { + const writer = apply(streamGetWriter, stream, []) as WritableStreamDefaultWriter; + const facade = { + __proto__: null, + write: (chunk?: T) => + observePrivatePromise(apply(writerWrite, writer, [chunk]) as Promise), + abort: (reason?: unknown) => + observePrivatePromise(apply(writerAbort, writer, [reason]) as Promise), + releaseLock: () => { + apply(writerReleaseLock, writer, []); + }, + }; + return freeze(facade); +} function ownData(value: T, key: K): T[K] | undefined { const descriptor = ownDescriptor(value, key); diff --git a/tests/integration/agent/executor-channel-intrinsics.test.ts b/tests/integration/agent/executor-channel-intrinsics.test.ts index dc9af3e9b3..b0c33cc220 100644 --- a/tests/integration/agent/executor-channel-intrinsics.test.ts +++ b/tests/integration/agent/executor-channel-intrinsics.test.ts @@ -9,7 +9,17 @@ import { import type { JsonValue } from "#veryfront/schemas/index.ts"; describe("private executor channel dispatch", () => { - for (const hook of ["operation lookup", "text codecs", "byte copies", "transport reads"]) { + for ( + const hook of [ + "operation lookup", + "text codecs", + "byte copies", + "transport reads", + "transport writes", + "queued payloads", + "call tracking", + ] + ) { it(`keeps request payloads out of replaced ${hook}`, async () => { const binding = { allocationId: "channel", invocationId: "channel", generation: 1 }; const marker = "synthetic-private-channel-message"; @@ -32,6 +42,9 @@ describe("private executor channel dispatch", () => { }); const originalSet = Uint8Array.prototype.set; const originalRead = ReadableStreamDefaultReader.prototype.read; + const originalWrite = WritableStreamDefaultWriter.prototype.write; + const originalPush = Array.prototype.push; + const originalAdd = Set.prototype.add; const decoder = new TextDecoder(); const originalGet = Map.prototype.get; const originalEncode = TextEncoder.prototype.encode; @@ -62,6 +75,24 @@ describe("private executor channel dispatch", () => { ) observations++; return Reflect.apply(originalSet, this, [source, offset]); }; + } else if (hook === "transport writes") { + WritableStreamDefaultWriter.prototype.write = function (chunk) { + if ( + chunk instanceof Uint8Array && + Reflect.apply(originalDecode, decoder, [chunk]).includes(marker) + ) observations++; + return Reflect.apply(originalWrite, this, [chunk]); + }; + } else if (hook === "queued payloads") { + Array.prototype.push = function (...items) { + for (const item of items) if (item?.value?.text === marker) observations++; + return Reflect.apply(originalPush, this, items); + }; + } else if (hook === "call tracking") { + Set.prototype.add = function (value) { + if (value?.pendingRequest?.value?.text === marker) observations++; + return Reflect.apply(originalAdd, this, [value]); + }; } else if (hook === "transport reads") { ReadableStreamDefaultReader.prototype.read = async function () { const result = await Reflect.apply(originalRead, this, []); @@ -85,6 +116,9 @@ describe("private executor channel dispatch", () => { received = await Array.fromAsync(caller.stream("agent.stream", { text: marker })); received.push(...await Array.fromAsync(caller.stream("agent.stream", { text: marker }))); } finally { + WritableStreamDefaultWriter.prototype.write = originalWrite; + Array.prototype.push = originalPush; + Set.prototype.add = originalAdd; Uint8Array.prototype.set = originalSet; ReadableStreamDefaultReader.prototype.read = originalRead; Map.prototype.get = originalGet; From 8f570a4f65177da2bad28516748de51ce3479327 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 21:51:55 +0200 Subject: [PATCH 114/194] fix(agent): consume private streams through captured iterators --- src/agent/executor/channel.ts | 5 +- src/agent/hosted/executor-agent-bridge.ts | 5 +- src/agent/hosted/executor-model-dispatch.ts | 5 +- src/agent/hosted/executor-runtime-prepare.ts | 5 +- src/agent/runtime/chat-stream-handler.ts | 5 +- .../runtime-loader/tool-input-status.ts | 13 +- src/runtime/runtime-bridge.ts | 9 +- src/security/private-iterator.test.ts | 55 +++++++ src/security/private-iterator.ts | 38 +++++ .../executor-iterator-intrinsics.test.ts | 134 ++++++++++++++++++ 10 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 src/security/private-iterator.test.ts create mode 100644 src/security/private-iterator.ts create mode 100644 tests/integration/agent/executor-iterator-intrinsics.test.ts diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index d0c5d634a6..d0f1c976fc 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -1,3 +1,4 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { @@ -461,7 +462,7 @@ class Channel implements ExecutorChannel { async #receive(): Promise { try { - for await (const frame of readExecutorFrames(this.#reader)) { + for await (const frame of getPrivateAsyncIterator(readExecutorFrames(this.#reader))) { if (this.#error) return; this.#accept(frame); } @@ -619,7 +620,7 @@ class Channel implements ExecutorChannel { if (call.ended || this.#error) return; await this.#send({ type: "data", id: call.id, index: call.sent++, value }); } else { - iterator = operation.handle(message.value, context)[Symbol.asyncIterator](); + iterator = getPrivateAsyncIterator(operation.handle(message.value, context)); while (!call.ended && !this.#error) { while ( call.sent - call.consumed >= EXECUTOR_STREAM_WINDOW && !call.ended && !this.#error diff --git a/src/agent/hosted/executor-agent-bridge.ts b/src/agent/hosted/executor-agent-bridge.ts index 59ccd735de..64ecea2053 100644 --- a/src/agent/hosted/executor-agent-bridge.ts +++ b/src/agent/hosted/executor-agent-bridge.ts @@ -1,3 +1,4 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { cancelPrivateStream, @@ -82,7 +83,9 @@ export function createExecutorAgentOperations(options: { ); phase = "stream"; yield { type: "ready" }; - for await (const event of readExecutorDataEvents(stream, context.signal)) { + for await ( + const event of getPrivateAsyncIterator(readExecutorDataEvents(stream, context.signal)) + ) { yield executorAgentJson({ type: "event", event }, "EXECUTOR_AGENT_INVALID_STREAM"); } } catch (error) { diff --git a/src/agent/hosted/executor-model-dispatch.ts b/src/agent/hosted/executor-model-dispatch.ts index c9471edf1f..ce025f715e 100644 --- a/src/agent/hosted/executor-model-dispatch.ts +++ b/src/agent/hosted/executor-model-dispatch.ts @@ -1,3 +1,4 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import { @@ -148,7 +149,9 @@ function createScopedHostedModelBroker( let admission: ReturnType | undefined; try { admission = name === "model.stream" ? admit(value) : undefined; - yield* operation.handle(admission?.input ?? value, boundContext); + yield* getPrivateAsyncIterator( + operation.handle(admission?.input ?? value, boundContext), + ); } catch (error) { boundContext.signal.throwIfAborted(); const failure = executorModelFailure(error); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 8788849a4c..82249873dd 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -1,3 +1,4 @@ +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 { @@ -822,10 +823,10 @@ export function createExecutorRuntimePreparation(input: Options) { ? undefined : privateMapGet(preparedOperations, "agent.stream"); if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); - yield* operation.handle(value, { + 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/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 05c0cbddaf..8397343c16 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,3 +1,4 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { closePrivateStream, createPrivateReadableStream, @@ -704,7 +705,7 @@ async function processActiveStream( let deliveryError: unknown; let streamOutcome!: StreamOutcome; try { - for await (const frame of run.frames) { + for await (const frame of getPrivateAsyncIterator(run.frames)) { if (frame.class === "semantic" && frame.event.type === "text_content") { callbacks?.onChunk?.(frame.event.delta); } @@ -1113,7 +1114,7 @@ export function processStreamInternal( // client even when the stream aborts or throws, so the finalizer runs in a // `finally`. It runs after the shadow compare so a synthesized event can // never perturb the legacy-vs-reducer snapshot the rollout gate reads. - const streamIterator = result.fullStream[Symbol.asyncIterator](); + const streamIterator = getPrivateAsyncIterator(result.fullStream); let streamIteratorReturned = false; /** Release the upstream iterator exactly once, whichever exit is taken. */ const returnStreamIteratorOnce = () => { diff --git a/src/provider/runtime-loader/tool-input-status.ts b/src/provider/runtime-loader/tool-input-status.ts index 702caaf035..ea8d7c434c 100644 --- a/src/provider/runtime-loader/tool-input-status.ts +++ b/src/provider/runtime-loader/tool-input-status.ts @@ -1,3 +1,4 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { normalizeTimerDurationMs } from "#veryfront/utils/timer.ts"; /** Shared tool input pending threshold ms value. */ @@ -77,8 +78,8 @@ export function withToolInputStatusTransitions( if (normalizedThresholdMs === 0) { throw new RangeError("thresholdMs must be greater than zero"); } - const iterator = stream[Symbol.asyncIterator](); - const returnSource = iterator.return?.bind(iterator); + const iterator = getPrivateAsyncIterator(stream); + const returnSource = iterator.return === undefined ? undefined : () => iterator.return!(); let resolveCancellation!: () => void; const lifecycle: ToolInputStatusLifecycle = { cancellation: new Promise((resolve) => { @@ -117,12 +118,12 @@ export function withToolInputStatusTransitions( closeSource(); }; - const transformed = applyToolInputStatusTransitions( + const transformed = getPrivateAsyncIterator(applyToolInputStatusTransitions( iterator, normalizedThresholdMs, lifecycle, closeSource, - ); + )); const wrapped: AsyncIterableIterator = { next() { @@ -130,11 +131,11 @@ export function withToolInputStatusTransitions( }, async return(value?: unknown) { requestCancellation(); - return await transformed.return(value); + return await transformed.return!(value); }, async throw(error?: unknown) { requestCancellation(); - return await transformed.throw(error); + return await transformed.throw!(error); }, [Symbol.asyncIterator]() { return this; diff --git a/src/runtime/runtime-bridge.ts b/src/runtime/runtime-bridge.ts index 3c6057cc57..d405ed13d3 100644 --- a/src/runtime/runtime-bridge.ts +++ b/src/runtime/runtime-bridge.ts @@ -1,3 +1,4 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; /** * Runtime Bridge * @@ -938,7 +939,7 @@ async function buildGenerateResultFromStream( const toolInputs = new Map(); const toolResults: NonNullable = []; - for await (const rawPart of mapReadableStream(stream)) { + for await (const rawPart of getPrivateAsyncIterator(mapReadableStream(stream))) { if (!rawPart || typeof rawPart !== "object" || !("type" in rawPart)) { continue; } @@ -1208,7 +1209,7 @@ async function* mapReadableStream(stream: ReadableStream): AsyncIterabl } async function* textDeltasFromStream(stream: ReadableStream): AsyncIterable { - for await (const materializedPart of mapReadableStream(stream)) { + for await (const materializedPart of getPrivateAsyncIterator(mapReadableStream(stream))) { if ( typeof materializedPart === "object" && materializedPart !== null && (materializedPart as { type?: unknown }).type === "text-delta" @@ -1272,10 +1273,10 @@ export function streamText(options: StreamTextOptions): RuntimeStreamResult { return { fullStream: (async function* () { - yield* mapReadableStream(await acquire("full")); + yield* getPrivateAsyncIterator(mapReadableStream(await acquire("full"))); })(), textStream: (async function* () { - yield* textDeltasFromStream(await acquire("text")); + yield* getPrivateAsyncIterator(textDeltasFromStream(await acquire("text"))); })(), }; } diff --git a/src/security/private-iterator.test.ts b/src/security/private-iterator.test.ts new file mode 100644 index 0000000000..9e88d2b1cd --- /dev/null +++ b/src/security/private-iterator.test.ts @@ -0,0 +1,55 @@ +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { getPrivateAsyncIterator } from "./private-iterator.ts"; + +describe("private async iterators", () => { + it("preserves next input, returned values, and generator cleanup", async () => { + let cleaned = false; + const source = (async function* (): AsyncGenerator { + try { + const input = yield "first"; + yield input; + return "finished"; + } finally { + cleaned = true; + } + })(); + const iterator = getPrivateAsyncIterator(source); + assertEquals(await iterator.next(), { done: false, value: "first" }); + assertEquals(await iterator.next("second"), { done: false, value: "second" }); + assertEquals(await iterator.return!("stopped"), { done: true, value: "stopped" }); + assertEquals(cleaned, true); + }); + + it("forwards thrown errors and still joins generator finalization", async () => { + let cleaned = false; + const iterator = getPrivateAsyncIterator((async function* () { + try { + yield "first"; + } finally { + cleaned = true; + } + })()); + await iterator.next(); + await assertRejects(() => iterator.throw!(new Error("Synthetic iterator failure")), Error); + assertEquals(cleaned, true); + }); + + it("preserves custom iterator receivers and optional cleanup methods", async () => { + let calls = 0; + const source: AsyncIterableIterator = { + next() { + assertEquals(this, source); + return Promise.resolve({ done: calls++ > 0, value: 1 }); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + const iterator = getPrivateAsyncIterator(source); + source.next = () => Promise.reject(new Error("Unexpected replacement")); + assertEquals(await Array.fromAsync(iterator), [1]); + assertEquals(iterator.return, undefined); + assertEquals(iterator.throw, undefined); + }); +}); diff --git a/src/security/private-iterator.ts b/src/security/private-iterator.ts new file mode 100644 index 0000000000..3ef553221d --- /dev/null +++ b/src/security/private-iterator.ts @@ -0,0 +1,38 @@ +import { observePrivatePromise } from "#veryfront/security/private-promise.ts"; + +const apply = Reflect.apply; +const freeze = Object.freeze; +const setPrototypeOf = Object.setPrototypeOf; +const isPrototypeOf = Object.prototype.isPrototypeOf; +const asyncIteratorSymbol: typeof Symbol.asyncIterator = Symbol.asyncIterator; +const generatorPrototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); +const generatorNext = generatorPrototype.next; +const generatorReturn = generatorPrototype.return; +const generatorThrow = generatorPrototype.throw; + +function isNativeGenerator(value: unknown): boolean { + return apply(isPrototypeOf, generatorPrototype, [value]) as boolean; +} + +/** Consume private generators without consulting mutable async-generator methods. */ +export function getPrivateAsyncIterator(source: AsyncIterable): AsyncIterableIterator { + const iterator = isNativeGenerator(source) + ? source as AsyncIterableIterator + : source[asyncIteratorSymbol](); + const native = isNativeGenerator(iterator); + const next = native ? generatorNext : iterator.next; + const close = native ? generatorReturn : iterator.return; + const fail = native ? generatorThrow : iterator.throw; + const invoke = (method: typeof next, args: unknown[]) => + observePrivatePromise(apply(method, iterator, args) as Promise>); + const facade: AsyncIterableIterator = { + next: (...args) => invoke(next, args), + return: close === undefined ? undefined : (value?: unknown) => invoke(close, [value]), + throw: fail === undefined ? undefined : (error?: unknown) => invoke(fail, [error]), + [asyncIteratorSymbol]() { + return this; + }, + }; + setPrototypeOf(facade, null); + return freeze(facade); +} diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts new file mode 100644 index 0000000000..09600912d2 --- /dev/null +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -0,0 +1,134 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { agent } from "#veryfront/agent/factory.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; +import { createExecutorRuntimePreparation } from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; + +describe("prepared executor private iteration", () => { + it("keeps stream requests and model output out of replaced async-generator methods", async () => { + const binding = { allocationId: "iterators", invocationId: "iterators", generation: 1 }; + const source = { type: "release", releaseId: "synthetic-release" } as const; + const modelId = "veryfront-cloud/openai/gpt-5.4"; + const coder = agent({ + id: "coder", + system: "Synthetic source instructions.", + model: modelId, + maxSteps: 3, + tools: {}, + }); + let facadeCleanups = 0; + let discoveryCleanups = 0; + const marker = "synthetic-private-iterator-marker"; + const model = scriptedModel([{ text: marker }]); + const discovery = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + signal: new AbortController().signal, + backend: { + load: () => + Promise.resolve({ + agents: new Map([[coder.id, coder]]), + tools: new Map(), + 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" }, + }), + cleanup: () => { + discoveryCleanups++; + return Promise.resolve(); + }, + }, + }); + const owner = createExecutorRuntimePreparation({ + binding, + source, + discovery, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, + }, + facades: { + hostTools: new Map(), + remoteToolSources: new Map(), + resolveModelRuntime: () => model, + cleanup: () => { + facadeCleanups++; + return Promise.resolve(); + }, + }, + }); + const forward = new TransformStream(); + const backward = new TransformStream(); + const broker = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const executor = createExecutorChannel({ + binding, + operations: owner.operations, + transport: { readable: forward.readable, writable: backward.writable }, + }); + const prototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); + const originalNext = prototype.next; + const originalReturn = prototype.return; + let observations = 0; + let frames: JsonValue[] = []; + const hook = (original: typeof originalNext) => + async function (this: unknown, ...args: unknown[]) { + const result = await Reflect.apply(original, this, args) as IteratorResult; + if (JSON.stringify(result.value)?.includes(marker)) observations++; + return result; + }; + try { + await Promise.all([broker.ready, executor.ready]); + prototype.next = hook(originalNext); + prototype.return = hook(originalReturn); + const prepared = await broker.request("runtime.prepare", { agentId: "coder" }) as { + ok: boolean; + value: { preparedRuntimeHandle: string }; + }; + assertEquals(prepared.ok, true); + frames = await Array.fromAsync(broker.stream("agent.stream", { + preparedRuntimeHandle: prepared.value.preparedRuntimeHandle, + messages: [{ + id: "synthetic-message", + role: "user", + parts: [{ type: "text", text: marker }], + timestamp: 1, + }], + })); + } finally { + prototype.next = originalNext; + prototype.return = originalReturn; + broker.close(); + executor.close(); + await Promise.all([broker.settled, executor.settled]); + await owner.close(); + await owner.settled; + } + assertEquals(frames[0], { type: "ready" }); + assertEquals(frames.at(-1), { type: "complete" }); + assertEquals(frames.some((frame) => JSON.stringify(frame).includes(marker)), true); + assertEquals(observations, 0); + assertEquals(facadeCleanups, 1); + assertEquals(discoveryCleanups, 1); + }); +}); From 5adf996fe31c90bf39545b4187418dd5bc0af723 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 22:08:05 +0200 Subject: [PATCH 115/194] fix(agent): protect managed streams and optional message fields --- src/agent/hosted/executor-model-bridge.ts | 3 +- src/agent/runtime/index.ts | 12 +- .../executor-iterator-intrinsics.test.ts | 256 ++++++++++-------- .../executor-model-stream-intrinsics.test.ts | 87 ++++++ 4 files changed, 234 insertions(+), 124 deletions(-) create mode 100644 tests/integration/agent/executor-model-stream-intrinsics.test.ts diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 64fd8104b0..2e2a9f1635 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -1,4 +1,5 @@ import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; +import { createPrivateReadableStream } from "#veryfront/security/private-stream.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { executorModelFailure, throwExecutorModelFailure } from "./executor-model-errors.ts"; import type { @@ -429,7 +430,7 @@ function createExecutorModelRuntime( const start = parseExecutorModelData(getExecutorModelStreamFrameSchema(), first.value); throwExecutorModelFailure(start); if (start.type !== "start") throw new TypeError("Invalid managed model stream start"); - const stream = new ReadableStream({ + const stream = createPrivateReadableStream({ async pull(controller) { try { const next = await iterator.next(); diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 1984ad3c1e..07af8f9820 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -636,15 +636,19 @@ function cloneMessageForCommit(message: Message): Message { const part = message.parts[index]; if (part !== undefined) parts[parts.length] = cloneMessagePartForCommit(part); } - return { + const timestamp = ObjectHasOwn(message, "timestamp") ? message.timestamp : undefined; + const metadata = ObjectHasOwn(message, "metadata") ? message.metadata : undefined; + const snapshot = { + __proto__: null, id: message.id, role: message.role, parts, - ...(message.timestamp === undefined ? {} : { timestamp: message.timestamp }), - ...(message.metadata === undefined + ...(timestamp === undefined ? {} : { timestamp }), + ...(metadata === undefined ? {} - : { metadata: cloneStructuredValuePreservingOpaque(message.metadata, true) }), + : { metadata: cloneStructuredValuePreservingOpaque(metadata, true) }), }; + return snapshot; } function providerValuesEqual( diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 09600912d2..578446aa01 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -9,126 +9,144 @@ import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpe import type { JsonValue } from "#veryfront/schemas/index.ts"; describe("prepared executor private iteration", () => { - it("keeps stream requests and model output out of replaced async-generator methods", async () => { - const binding = { allocationId: "iterators", invocationId: "iterators", generation: 1 }; - const source = { type: "release", releaseId: "synthetic-release" } as const; - const modelId = "veryfront-cloud/openai/gpt-5.4"; - const coder = agent({ - id: "coder", - system: "Synthetic source instructions.", - model: modelId, - maxSteps: 3, - tools: {}, - }); - let facadeCleanups = 0; - let discoveryCleanups = 0; - const marker = "synthetic-private-iterator-marker"; - const model = scriptedModel([{ text: marker }]); - const discovery = createExecutorDiscovery({ - binding, - source, - projectDir: "/synthetic-project", - signal: new AbortController().signal, - backend: { - load: () => - Promise.resolve({ - agents: new Map([[coder.id, coder]]), - tools: new Map(), - 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" }, - }), - cleanup: () => { - discoveryCleanups++; - return Promise.resolve(); + for (const probe of ["async generators", "inherited metadata"]) { + it(`keeps stream requests and model output out of replaced ${probe}`, async () => { + const binding = { allocationId: "iterators", invocationId: "iterators", generation: 1 }; + const source = { type: "release", releaseId: "synthetic-release" } as const; + const modelId = "veryfront-cloud/openai/gpt-5.4"; + const coder = agent({ + id: "coder", + system: "Synthetic source instructions.", + model: modelId, + maxSteps: 3, + tools: {}, + }); + let facadeCleanups = 0; + let discoveryCleanups = 0; + const marker = "synthetic-private-iterator-marker"; + const model = scriptedModel([{ text: marker }]); + const discovery = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + signal: new AbortController().signal, + backend: { + load: () => + Promise.resolve({ + agents: new Map([[coder.id, coder]]), + tools: new Map(), + 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" }, + }), + cleanup: () => { + discoveryCleanups++; + return Promise.resolve(); + }, }, - }, - }); - const owner = createExecutorRuntimePreparation({ - binding, - source, - discovery, - grant: { - agentId: "coder", - defaultModelId: modelId, - maxSteps: 5, - models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), - allowedToolNames: [], - hostToolFacadeIds: [], - remoteToolSourceIds: [], - execution: { kind: "ephemeral", projectId: null }, - }, - facades: { - hostTools: new Map(), - remoteToolSources: new Map(), - resolveModelRuntime: () => model, - cleanup: () => { - facadeCleanups++; - return Promise.resolve(); + }); + const owner = createExecutorRuntimePreparation({ + binding, + source, + discovery, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 5, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, }, - }, - }); - const forward = new TransformStream(); - const backward = new TransformStream(); - const broker = createExecutorChannel({ - binding, - transport: { readable: backward.readable, writable: forward.writable }, - }); - const executor = createExecutorChannel({ - binding, - operations: owner.operations, - transport: { readable: forward.readable, writable: backward.writable }, + facades: { + hostTools: new Map(), + remoteToolSources: new Map(), + resolveModelRuntime: () => model, + cleanup: () => { + facadeCleanups++; + return Promise.resolve(); + }, + }, + }); + const forward = new TransformStream(); + const backward = new TransformStream(); + const broker = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const executor = createExecutorChannel({ + binding, + operations: owner.operations, + transport: { readable: forward.readable, writable: backward.writable }, + }); + const prototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); + const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); + const originalNext = prototype.next; + const originalReturn = prototype.return; + let observations = 0; + let frames: JsonValue[] = []; + const hook = (original: typeof originalNext) => + async function (this: unknown, ...args: unknown[]) { + const result = await Reflect.apply(original, this, args) as IteratorResult; + if (JSON.stringify(result.value)?.includes(marker)) observations++; + return result; + }; + try { + await Promise.all([broker.ready, executor.ready]); + if (probe === "async generators") { + prototype.next = hook(originalNext); + prototype.return = hook(originalReturn); + } else { + Object.defineProperty(Object.prototype, "metadata", { + configurable: true, + get() { + const parts = Object.getOwnPropertyDescriptor(this, "parts")?.value; + if (Array.isArray(parts) && parts.some((part) => part?.text === marker)) { + observations++; + } + return undefined; + }, + }); + } + const prepared = await broker.request("runtime.prepare", { agentId: "coder" }) as { + ok: boolean; + value: { preparedRuntimeHandle: string }; + }; + assertEquals(prepared.ok, true); + frames = await Array.fromAsync(broker.stream("agent.stream", { + preparedRuntimeHandle: prepared.value.preparedRuntimeHandle, + messages: [{ + id: "synthetic-message", + role: "user", + parts: [{ type: "text", text: marker }], + timestamp: 1, + }], + })); + } finally { + if (originalMetadata) Object.defineProperty(Object.prototype, "metadata", originalMetadata); + else Reflect.deleteProperty(Object.prototype, "metadata"); + prototype.next = originalNext; + prototype.return = originalReturn; + broker.close(); + executor.close(); + await Promise.all([broker.settled, executor.settled]); + await owner.close(); + await owner.settled; + } + assertEquals(frames[0], { type: "ready" }); + assertEquals(frames.at(-1), { type: "complete" }); + assertEquals(frames.some((frame) => JSON.stringify(frame).includes(marker)), true); + assertEquals(observations, 0); + assertEquals(facadeCleanups, 1); + assertEquals(discoveryCleanups, 1); }); - const prototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); - const originalNext = prototype.next; - const originalReturn = prototype.return; - let observations = 0; - let frames: JsonValue[] = []; - const hook = (original: typeof originalNext) => - async function (this: unknown, ...args: unknown[]) { - const result = await Reflect.apply(original, this, args) as IteratorResult; - if (JSON.stringify(result.value)?.includes(marker)) observations++; - return result; - }; - try { - await Promise.all([broker.ready, executor.ready]); - prototype.next = hook(originalNext); - prototype.return = hook(originalReturn); - const prepared = await broker.request("runtime.prepare", { agentId: "coder" }) as { - ok: boolean; - value: { preparedRuntimeHandle: string }; - }; - assertEquals(prepared.ok, true); - frames = await Array.fromAsync(broker.stream("agent.stream", { - preparedRuntimeHandle: prepared.value.preparedRuntimeHandle, - messages: [{ - id: "synthetic-message", - role: "user", - parts: [{ type: "text", text: marker }], - timestamp: 1, - }], - })); - } finally { - prototype.next = originalNext; - prototype.return = originalReturn; - broker.close(); - executor.close(); - await Promise.all([broker.settled, executor.settled]); - await owner.close(); - await owner.settled; - } - assertEquals(frames[0], { type: "ready" }); - assertEquals(frames.at(-1), { type: "complete" }); - assertEquals(frames.some((frame) => JSON.stringify(frame).includes(marker)), true); - assertEquals(observations, 0); - assertEquals(facadeCleanups, 1); - assertEquals(discoveryCleanups, 1); - }); + } }); diff --git a/tests/integration/agent/executor-model-stream-intrinsics.test.ts b/tests/integration/agent/executor-model-stream-intrinsics.test.ts new file mode 100644 index 0000000000..5f0a9143ec --- /dev/null +++ b/tests/integration/agent/executor-model-stream-intrinsics.test.ts @@ -0,0 +1,87 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; + +describe("managed model private stream construction", () => { + it("keeps model output out of a replaced stream constructor", async () => { + const marker = "synthetic-private-managed-model-output"; + const modelId = "veryfront-cloud/openai/synthetic-model"; + const NativeReadableStream = ReadableStream; + const source = new NativeReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: marker }); + controller.close(); + }, + }); + const binding = { allocationId: "model-stream", invocationId: "model-stream", generation: 1 }; + const forward = new TransformStream(); + const backward = new TransformStream(); + const allowedModelIds = new Set([modelId]); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + specificationVersion: "v3", + modelId: "synthetic-model", + provider: "openai", + doGenerate: () => Promise.reject(new Error("Unexpected generation")), + doStream: () => Promise.resolve({ stream: source }), + }), + }), + }); + const executor = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + let observations = 0; + let chunks: unknown[] = []; + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: executor, + allowedModelIds, + }); + globalThis.ReadableStream = new Proxy(NativeReadableStream, { + construct(target, args) { + const underlying = args[0] as UnderlyingDefaultSource; + const pull = underlying.pull; + const wrapped = { + ...underlying, + pull(controller: ReadableStreamDefaultController) { + const facade = { + enqueue(chunk: unknown) { + if (JSON.stringify(chunk)?.includes(marker)) observations++; + controller.enqueue(chunk); + }, + close: () => controller.close(), + error: (error: unknown) => controller.error(error), + get desiredSize() { + return controller.desiredSize; + }, + }; + return pull === undefined ? undefined : Reflect.apply(pull, underlying, [facade]); + }, + }; + return Reflect.construct(target, [wrapped, args[1]]); + }, + }); + const result = await resolver(modelId)!.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Synthetic input" }] }], + }); + chunks = await Array.fromAsync(result.stream); + } finally { + globalThis.ReadableStream = NativeReadableStream; + executor.close(); + broker.close(); + await Promise.all([executor.settled, broker.settled]); + } + assertEquals(chunks, [{ type: "text-delta", delta: marker }]); + assertEquals(observations, 0); + }); +}); From 384b94af0cdb2c7b04099706097d4198df367a95 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 22:16:23 +0200 Subject: [PATCH 116/194] fix(agent): retain private channel records and bounded data serialization --- src/agent/executor/channel.ts | 19 +++++---- src/agent/executor/protocol.test.ts | 29 +++++++++++++ src/agent/executor/protocol.ts | 4 +- src/security/private-json.test.ts | 41 +++++++++++++++++++ src/security/private-json.ts | 63 ++++++++++++++++++++++++++--- src/security/private-set.ts | 29 ++++++++----- src/security/private-stream.ts | 10 +++++ 7 files changed, 171 insertions(+), 24 deletions(-) diff --git a/src/agent/executor/channel.ts b/src/agent/executor/channel.ts index d0f1c976fc..8202665b85 100644 --- a/src/agent/executor/channel.ts +++ b/src/agent/executor/channel.ts @@ -1,3 +1,4 @@ +import { createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; @@ -27,6 +28,7 @@ import { } from "./protocol.ts"; const apply = Reflect.apply; +const setPrototypeOf = Object.setPrototypeOf; const mapGet = Map.prototype.get; const mapSet = Map.prototype.set; const mapDelete = Map.prototype.delete; @@ -119,7 +121,7 @@ export interface ExecutorChannel { } type EndError = Extract["error"]; -type Deferred = ReturnType>; +type Deferred = ReturnType>; interface OutgoingCall { id: number; @@ -192,9 +194,9 @@ class Channel implements ExecutorChannel { readonly #maxRetainedBytes: number; #retainedBytes = 0; readonly #controller = new AbortController(); - readonly #ready = Promise.withResolvers(); - readonly #closed = Promise.withResolvers(); - readonly #settled = Promise.withResolvers(); + readonly #ready = createPrivateDeferred(); + readonly #closed = createPrivateDeferred(); + readonly #settled = createPrivateDeferred(); readonly #outgoing = createPrivateSet(); readonly #outgoingById = new Map(); readonly #incoming = new Map(); @@ -332,7 +334,7 @@ class Channel implements ExecutorChannel { inputBytes: this.#retainPayload(snapshot.value), mode, queue: [], - completion: Promise.withResolvers(), + completion: createPrivateDeferred(), received: 0, consumed: 0, unaryBytes: 0, @@ -341,6 +343,7 @@ class Channel implements ExecutorChannel { cancelled: false, reading: false, }; + setPrototypeOf(call, null); void call.completion.promise.catch(() => {}); this.#outgoing.add(call); call.timer = setTimeout(() => this.#cancelOutgoing(call, "deadline"), timeoutMs); @@ -437,7 +440,7 @@ class Channel implements ExecutorChannel { try { // Keep the admission slot and a deadline until release is written. if (call.id && !this.#error) { - call.releaseAck = Promise.withResolvers(); + call.releaseAck = createPrivateDeferred(); const acknowledgement = call.releaseAck; void acknowledgement.promise.catch(() => {}); call.cancellationTimer = setTimeout( @@ -587,6 +590,7 @@ class Channel implements ExecutorChannel { cancelled: false, settled: false, }; + setPrototypeOf(call, null); apply(mapSet, this.#incoming, [call.id, call]); call.timer = setTimeout(() => this.#abortIncoming(call, "deadline"), timeout); this.#activeHandlers++; @@ -615,6 +619,7 @@ class Channel implements ExecutorChannel { return; } const context = { binding: this.#binding, signal: call.controller.signal, deadline }; + setPrototypeOf(context, null); if (operation.mode === "unary") { const value = await operation.handle(message.value, context); if (call.ended || this.#error) return; @@ -711,7 +716,7 @@ class Channel implements ExecutorChannel { return Promise.reject(this.#error); } this.#sendSequence++; - const done = Promise.withResolvers(); + const done = createPrivateDeferred(); appendPrivateQueue(this.#writes, { bytes, done }); this.#queuedBytes += privateByteLength(bytes); if (!this.#writing) void this.#flush(); diff --git a/src/agent/executor/protocol.test.ts b/src/agent/executor/protocol.test.ts index bab954185f..47e1a4f51f 100644 --- a/src/agent/executor/protocol.test.ts +++ b/src/agent/executor/protocol.test.ts @@ -66,6 +66,35 @@ async function tick(): Promise { } describe("executor byte protocol", () => { + it("reads native transport bytes without consulting an overridden reader method", async () => { + const frame = envelope({ + type: "data", + id: 1, + index: 0, + value: { text: "Synthetic private frame" }, + }); + const reader = new ReadableStream({ + start(controller) { + controller.enqueue(encodeExecutorFrame(frame)); + controller.close(); + }, + }).getReader(); + const original = reader.read; + let reads = 0; + Object.defineProperty(reader, "read", { + value: () => { + reads++; + return original.call(reader); + }, + }); + try { + assertEquals(await Array.fromAsync(readExecutorFrames(reader)), [frame]); + assertEquals(reads, 0); + } finally { + reader.releaseLock(); + } + }); + it("decodes fragmented prefixes, split UTF-8 and coalesced frames", async () => { const frames = [ envelope({ type: "hello" }), diff --git a/src/agent/executor/protocol.ts b/src/agent/executor/protocol.ts index ebb0cb4805..b6ccbd7fea 100644 --- a/src/agent/executor/protocol.ts +++ b/src/agent/executor/protocol.ts @@ -1,4 +1,5 @@ import { createPrivateTextDecoder, encodePrivateText } from "#veryfront/security/private-text.ts"; +import { protectPrivateStreamReader } from "#veryfront/security/private-stream.ts"; import { isPrivateUint8Array, privateByteLength, @@ -105,13 +106,14 @@ export function encodeExecutorFrame(frame: ExecutorFrame): Uint8Array { export async function* readExecutorFrames( reader: ReadableStreamDefaultReader, ): AsyncGenerator { + const privateReader = protectPrivateStreamReader(reader); const prefix = new PrivateUint8Array(4); let prefixOffset = 0; let payload: Uint8Array | undefined; let payloadOffset = 0; const decoder = createPrivateTextDecoder("utf-8", { fatal: true }); while (true) { - const { value: chunk, done } = await reader.read(); + const { value: chunk, done } = await privateReader.read(); if (done) { if (prefixOffset || payload) throw new ExecutorProtocolError("Truncated executor frame"); return; diff --git a/src/security/private-json.test.ts b/src/security/private-json.test.ts index 6ebe07056c..fd499e01d3 100644 --- a/src/security/private-json.test.ts +++ b/src/security/private-json.test.ts @@ -3,6 +3,47 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { privateJsonStringify } from "./private-json.ts"; describe("private JSON serialization", () => { + it("preserves native scalar data without calling their serialization methods", () => { + const value = { + text: "å🙂", + list: [undefined, NaN, Infinity], + date: new Date(0), + url: new URL("https://example.test/path"), + }; + assertEquals(privateJsonStringify(value, null, 2), JSON.stringify(value, null, 2)); + assertEquals(privateJsonStringify(new Date(NaN)), "null"); + assertEquals(privateJsonStringify(() => {}), undefined); + }); + + it("rejects oversized sparse arrays and unsupported replacers", () => { + assertThrows(() => privateJsonStringify(new Array(100_001)), TypeError); + assertThrows(() => privateJsonStringify({ keep: 1 }, ["keep"] as never), TypeError); + assertThrows(() => privateJsonStringify({ keep: 1 }, (() => undefined) as never), TypeError); + }); + + it("serializes own data without invoking inherited object or array hooks", () => { + let reads = 0; + const hook = { + get toJSON() { + reads++; + return function (this: unknown) { + return this; + }; + }, + }; + const event = Object.create(hook, { + text: { value: "Synthetic private output", enumerable: true }, + }); + const events = [event]; + const arrayPrototype = Object.create(Array.prototype, Object.getOwnPropertyDescriptors(hook)); + Object.setPrototypeOf(events, arrayPrototype); + assertEquals( + privateJsonStringify({ events, optional: undefined }), + '{"events":[{"text":"Synthetic private output"}]}', + ); + assertEquals(reads, 0); + }); + it("preserves JSON data, omission rules, sparse arrays, and indentation", () => { const value = { text: "hello", nested: [1, , undefined, null], absent: undefined }; assertEquals(privateJsonStringify(value), JSON.stringify(value)); diff --git a/src/security/private-json.ts b/src/security/private-json.ts index ca0b6964d0..588bf2cc60 100644 --- a/src/security/private-json.ts +++ b/src/security/private-json.ts @@ -12,6 +12,41 @@ const NativeSet = Set; const setHas = Set.prototype.has; const setAdd = Set.prototype.add; const setDelete = Set.prototype.delete; +const getPrototypeOf = Object.getPrototypeOf; +const objectPrototype = Object.prototype; +const NativeTypeError = TypeError; +const dateTime = Date.prototype.getTime; +const dateIso = Date.prototype.toISOString; +const urlHref = descriptor(URL.prototype, "href")!.get!; +const numberValue = Number.prototype.valueOf; +const stringValue = String.prototype.valueOf; +const booleanValue = Boolean.prototype.valueOf; +const bigintValue = BigInt.prototype.valueOf; +const finite = Number.isFinite; +const notScalar = Symbol("not-native-json-scalar"); + +function nativeScalar(value: unknown): unknown { + try { + const time = apply(dateTime, value, []) as number; + return finite(time) ? apply(dateIso, value, []) : null; + } catch { /* Not a native date. */ } + try { + return apply(urlHref, value, []); + } catch { /* Not a native URL. */ } + try { + return apply(numberValue, value, []); + } catch { /* Not a boxed number. */ } + try { + return apply(stringValue, value, []); + } catch { /* Not a boxed string. */ } + try { + return apply(booleanValue, value, []); + } catch { /* Not a boxed boolean. */ } + try { + return apply(bigintValue, value, []); + } catch { /* Not a boxed bigint. */ } + return notScalar; +} /** Serialize own data without invoking accessors or inherited or own toJSON methods. */ export function privateJsonStringify( @@ -19,12 +54,30 @@ export function privateJsonStringify( _replacer: null = null, space?: string | number, ) { + if (_replacer !== null) { + throw new NativeTypeError("Private JSON supports data-only serialization"); + } const ancestors = new NativeSet(); - const copy = (input: unknown): unknown => { + let remaining = 100_000; + const copy = (input: unknown, depth = 0): unknown => { + if (--remaining < 0 || depth > 128) { + throw new NativeTypeError("Private JSON data exceeds its structural limit"); + } if (typeof input === "function") return undefined; - if (typeof input === "bigint") throw new TypeError("Cannot serialize bigint data"); + if (typeof input === "bigint") throw new NativeTypeError("Cannot serialize bigint data"); if (input === null || typeof input !== "object") return input; - if (apply(setHas, ancestors, [input])) throw new TypeError("Cannot serialize circular data"); + const array = isArray(input); + const prototype = getPrototypeOf(input); + if (!array && prototype !== null && prototype !== objectPrototype) { + const scalar = nativeScalar(input); + if (scalar !== notScalar) return copy(scalar, depth + 1); + } + if (array && input.length > 100_000) { + throw new NativeTypeError("Private JSON data exceeds its structural limit"); + } + if (apply(setHas, ancestors, [input])) { + throw new NativeTypeError("Cannot serialize circular data"); + } apply(setAdd, ancestors, [input]); try { const output = isArray(input) ? [] : {}; @@ -36,11 +89,11 @@ export function privateJsonStringify( const property = descriptor(input, key); if (!property || (!property.enumerable && !(isArray(input) && key === "length"))) continue; if (!hasOwn(property, "value")) { - throw new TypeError("Private JSON requires data properties"); + throw new NativeTypeError("Private JSON requires data properties"); } const copiedProperty = { __proto__: null, - value: copy(property.value), + value: copy(property.value, depth + 1), enumerable: property.enumerable, writable: true, configurable: key !== "length" || !isArray(input), diff --git a/src/security/private-set.ts b/src/security/private-set.ts index 1e02f649fc..aa352aa581 100644 --- a/src/security/private-set.ts +++ b/src/security/private-set.ts @@ -1,11 +1,14 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + const SetConstructor = Set; const apply = Reflect.apply; -const defineProperties = Object.defineProperties; +const defineProperty = Object.defineProperty; const freeze = Object.freeze; const isArray = Array.isArray; const setAdd = Set.prototype.add; const setHas = Set.prototype.has; const setDelete = Set.prototype.delete; +const setClear = Set.prototype.clear; const setValues = Set.prototype.values; const iteratorSymbol: typeof Symbol.iterator = Symbol.iterator; const iteratorNext = Object.getPrototypeOf(new SetConstructor().values()).next; @@ -20,22 +23,26 @@ export function createPrivateSet(values?: Iterable): Set { }; const iterate = (): IterableIterator => { const iterator = apply(setValues, set, []); - return freeze({ + const facade = { + __proto__: null, next: () => apply(iteratorNext, iterator, []) as IteratorResult, [iteratorSymbol]() { return this; }, - }); + }; + return freeze(facade); }; - defineProperties(set, { - add: { value: add }, - has: { value: (value: T) => apply(setHas, set, [value]) as boolean }, - delete: { value: (value: T) => apply(setDelete, set, [value]) as boolean }, - size: { get: () => apply(setSize, set, []) as number }, - values: { value: iterate }, - keys: { value: iterate }, - [iteratorSymbol]: { value: iterate }, + defineOwnDataProperty(set, "add", add); + defineOwnDataProperty(set, "has", (value: T) => apply(setHas, set, [value]) as boolean); + defineOwnDataProperty(set, "delete", (value: T) => apply(setDelete, set, [value]) as boolean); + defineOwnDataProperty(set, "clear", () => { + apply(setClear, set, []); }); + defineOwnDataProperty(set, "values", iterate); + defineOwnDataProperty(set, "keys", iterate); + defineOwnDataProperty(set, iteratorSymbol, iterate); + const sizeDescriptor = { __proto__: null, get: () => apply(setSize, set, []) as number }; + defineProperty(set, "size", sizeDescriptor); if (isArray(values)) { for (let index = 0; index < values.length; index++) add(values[index]); } else if (values) { diff --git a/src/security/private-stream.ts b/src/security/private-stream.ts index 056a30e5d5..796f2cae09 100644 --- a/src/security/private-stream.ts +++ b/src/security/private-stream.ts @@ -1,3 +1,4 @@ +import { createPrivateWeakStore } from "#veryfront/security/private-weak-store.ts"; import { chainPrivatePromise, observePrivatePromise, @@ -20,6 +21,7 @@ const controllerDesiredSize = ownDescriptor( const streamGetReader = ReadableStream.prototype.getReader; const streamCancel = ReadableStream.prototype.cancel; const streamLocked = Object.getOwnPropertyDescriptor(ReadableStream.prototype, "locked")!.get!; +const ownedReaders = createPrivateWeakStore, true>(); const readerRead = ReadableStreamDefaultReader.prototype.read; const readerCancel = ReadableStreamDefaultReader.prototype.cancel; const readerReleaseLock = ReadableStreamDefaultReader.prototype.releaseLock; @@ -125,6 +127,13 @@ export function getPrivateStreamReader( stream: ReadableStream, ): ReadableStreamDefaultReader { const reader = apply(streamGetReader, stream, []) as ReadableStreamDefaultReader; + return protectPrivateStreamReader(reader); +} + +export function protectPrivateStreamReader( + reader: ReadableStreamDefaultReader, +): ReadableStreamDefaultReader { + if (ownedReaders.get(reader)) return reader; const facade: ReadableStreamDefaultReader = { read: () => observePrivatePromise(apply(readerRead, reader, []) as Promise>), @@ -138,6 +147,7 @@ export function getPrivateStreamReader( }, }; setPrototypeOf(facade, null); + ownedReaders.set(facade, true); return freeze(facade); } From 65a19e2639753d98cd1ec33e31e2ca408a2f6b3f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 22:33:22 +0200 Subject: [PATCH 117/194] fix(agent): enforce thinking budgets and protect turn processing --- .../slash-command-artifact-policy.ts | 3 +- src/agent/hosted/executor-model-grant.ts | 7 +- .../hosted/executor-runtime-prepare.test.ts | 78 +++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 26 ++++- src/agent/middleware/chain.ts | 4 +- src/agent/middleware/security/validator.ts | 14 ++- src/agent/runtime/index.ts | 103 +++++++++++------- src/agent/runtime/skill-policy-enforcement.ts | 3 +- ...xt-generation-runtime-message-converter.ts | 12 +- src/security/private-array.test.ts | 22 ++++ src/security/private-array.ts | 15 +++ src/security/private-text.ts | 10 ++ .../executor-iterator-intrinsics.test.ts | 44 +++++++- ...xecutor-recovery-string-intrinsics.test.ts | 67 ++++++++++++ 14 files changed, 352 insertions(+), 56 deletions(-) create mode 100644 src/security/private-array.test.ts create mode 100644 tests/integration/agent/executor-recovery-string-intrinsics.test.ts diff --git a/src/agent/artifacts/slash-command-artifact-policy.ts b/src/agent/artifacts/slash-command-artifact-policy.ts index 144263e0dc..d682d148bd 100644 --- a/src/agent/artifacts/slash-command-artifact-policy.ts +++ b/src/agent/artifacts/slash-command-artifact-policy.ts @@ -132,7 +132,8 @@ function containsSlashCommand(messages: readonly unknown[]): boolean { function containsExactArtifactPath(messages: readonly unknown[]): boolean { const toolCallNamesById = new Map(); - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; if (!isRecord(message) || !Array.isArray(message.content)) { continue; } diff --git a/src/agent/hosted/executor-model-grant.ts b/src/agent/hosted/executor-model-grant.ts index d752b0cb3b..157430ee51 100644 --- a/src/agent/hosted/executor-model-grant.ts +++ b/src/agent/hosted/executor-model-grant.ts @@ -75,7 +75,10 @@ function assertSingleCompletion(options: ExecutorModelDispatch["options"]): void for (const bucket of Object.values(options.providerOptions ?? {})) inspect(bucket); } -function additiveReasoningTokens(request: ExecutorModelDispatch): number { +/** Internal output allowance reserved by the effective provider thinking configuration. */ +export function getExecutorModelAdditiveReasoningTokens( + request: Pick, +): number { if (resolveModelCallProvider(request.model) !== "anthropic") return 0; const reasoning = buildModelCallContextRequest(request.model, request.options)?.reasoning; if (request.options.reasoning?.enabled === true) { @@ -188,7 +191,7 @@ export function createExecutorModelAdmission( const policy = policies.get(request.model.id); if (!policy) throw new TypeError("Executor model is not granted"); assertSingleCompletion(request.options); - const budget = additiveReasoningTokens(request); + const budget = getExecutorModelAdditiveReasoningTokens(request); const available = policy.maxOutputTokens - budget; const maxOutputTokens = request.options.maxOutputTokens ?? available; if ( diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 2619597e65..859a30cfa3 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -15,6 +15,7 @@ import type { ToolDefinition, } from "#veryfront/tool"; import { registerModelRuntimeResolverRevoker } from "#veryfront/agent/runtime/model-transport.ts"; +import { createExecutorModelAdmission } from "./executor-model-grant.ts"; import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; import { agent } from "#veryfront/agent/factory.ts"; import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; @@ -1842,6 +1843,61 @@ Synthetic source instructions.`, }); } + it("reserves the catalog thinking budget when the request omits thinking and output limits", async () => { + const selectedModel = "veryfront-cloud/anthropic/claude-sonnet-4-6"; + let captured: ModelRuntimeCallOptions | undefined; + const f = fixture({ + grant: { + ...grant, + defaultModelId: selectedModel, + models: new Map([[selectedModel, { maxOutputTokens: 8192, providerToolNames: [] }]]), + }, + facades: { + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + captured = options as ModelRuntimeCallOptions; + return finishStream(); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assert(captured); + assertEquals(captured.reasoning, { enabled: true, budgetTokens: 2048 }); + assertEquals(captured.maxOutputTokens, 6144); + } finally { + await f.owner.close(); + } + }); + + for ( + const request of [ + { agentId: "coder", thinking: { enabled: true, budgetTokens: 8192 } }, + { agentId: "coder", thinking: { enabled: true, budgetTokens: 4096 }, maxOutputTokens: 4097 }, + ] + ) { + it(`rejects preparation when fixed thinking leaves insufficient output allowance: ${JSON.stringify(request)}`, async () => { + const selectedModel = "veryfront-cloud/anthropic/claude-sonnet-4-6"; + const f = fixture({ + grant: { + ...grant, + defaultModelId: selectedModel, + models: new Map([[selectedModel, { maxOutputTokens: 8192, providerToolNames: [] }]]), + }, + }); + try { + assertEquals(await prepare(f.owner, request as JsonValue), { + ok: false, + code: "EXECUTOR_RUNTIME_NOT_GRANTED", + }); + } finally { + await f.owner.close(); + } + }); + } + for ( const selectedModel of [ modelId, @@ -1896,6 +1952,28 @@ Synthetic source instructions.`, } : undefined, ); + const admission = createExecutorModelAdmission({ + maxCalls: 1, + maxConcurrentCalls: 1, + models: new Map([[selectedModel, { maxOutputTokens: 8192, providerTools: [] }]]), + }, new Set([selectedModel])); + const expectedOutput = selectedModel.includes("claude-sonnet") && thinking.enabled + ? 4096 + : 8192; + assertEquals(captured.maxOutputTokens, expectedOutput); + assertEquals( + admission.normalize({ + identity: { binding, sequence: 1 }, + mode: "stream", + model: { + id: selectedModel, + modelId: selectedModel, + provider: selectedModel.includes("anthropic") ? "anthropic" : "openai", + }, + options: captured, + }).maxOutputTokens, + expectedOutput, + ); assertPersistedModelOptions({ identity: { binding, sequence: 1 }, mode: "stream", diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 82249873dd..a6dbeb9fc4 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -8,7 +8,14 @@ import { resolvePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; -import { VERYFRONT_CLOUD_MODEL_PREFIX } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; +import { + getVeryfrontCloudProviderFromModelId, + resolveVeryfrontCloudModelThinking, + resolveVeryfrontCloudReasoningOption, + resolveVeryfrontCloudThinkingProviderOptions, + 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"; @@ -446,6 +453,19 @@ export function createExecutorRuntimePreparation(input: Options) { (request.maxOutputTokens !== undefined && request.maxOutputTokens > modelGrant.maxOutputTokens) ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + const thinking = request.thinking ?? definition.thinking ?? + resolveVeryfrontCloudModelThinking(modelId); + const reasoning = resolveVeryfrontCloudReasoningOption(modelId, thinking); + const providerOptions = resolveVeryfrontCloudThinkingProviderOptions(modelId, thinking); + const reasoningBudget = getExecutorModelAdditiveReasoningTokens({ + model: { id: modelId, modelId, provider: getVeryfrontCloudProviderFromModelId(modelId) }, + options: { prompt: [], reasoning, providerOptions }, + }); + const availableOutputTokens = modelGrant.maxOutputTokens - reasoningBudget; + if ( + availableOutputTokens <= 0 || + (request.maxOutputTokens !== undefined && request.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 @@ -586,13 +606,13 @@ export function createExecutorRuntimePreparation(input: Options) { }) : definition.system ?? definition.instructions), temperature: request.temperature ?? definition.temperature, - thinking: request.thinking ?? definition.thinking, + thinking, maxSteps: mathMin( request.maxSteps ?? grant.maxSteps, definition.maxSteps ?? grant.maxSteps, grant.maxSteps, ), - maxOutputTokens: request.maxOutputTokens ?? modelGrant.maxOutputTokens, + maxOutputTokens: request.maxOutputTokens ?? availableOutputTokens, allowedTools: allowedToolNames, allowedProviderTools: providerToolNames, availableSkillIds: skills.allowedSkillIds, diff --git a/src/agent/middleware/chain.ts b/src/agent/middleware/chain.ts index dca047fab7..899cd8f194 100644 --- a/src/agent/middleware/chain.ts +++ b/src/agent/middleware/chain.ts @@ -120,7 +120,9 @@ class ObservedContinuationPromise extends Promise { onRejected?: ContinuationThenHandler, ): Promise { markContinuationObserved(this); - const derived = super.then(onFulfilled, onRejected); + const derived = ReflectApply(PromiseThen, this, [onFulfilled, onRejected]) as Promise< + TResult1 | TResult2 + >; if (this.onRejection) { const observedDerived = derived as ObservedContinuationPromise; if (!PROMISE_SPECIES_SUPPORTED || typeof observedDerived.isObserved !== "function") { diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 70a4c9333e..77be775090 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,4 +1,4 @@ -import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { concatPrivateArrays, mapPrivateArray } from "#veryfront/security/private-array.ts"; import { isDeepStrictEqual } from "node:util"; import type { AgentContext, @@ -561,7 +561,8 @@ function extractAdjacentRuns( run = []; }; - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; if (message.role !== role) { if (role === "user" && message.role === "tool") continue; if (role === "user" && message.role === "system") continue; @@ -1483,7 +1484,9 @@ export function securityMiddleware( const trusted = new Set(systemMessages); const callers = new Set(callerSystemMessages); const providerRuns: ProviderValidationRun[] = []; - for (const run of extractMergedSystemRuns([...systemMessages, ...callerMessages])) { + for ( + const run of extractMergedSystemRuns(concatPrivateArrays(systemMessages, callerMessages)) + ) { if ( !run.some((message) => trusted.has(message)) || !run.some((message) => callers.has(message)) @@ -1535,7 +1538,10 @@ export function securityMiddleware( ], } : { texts: [], assembled: [] }; - const runTexts = extractMergedRunTexts([...history, ...turnInput], new Set(turnInput)); + const runTexts = extractMergedRunTexts( + concatPrivateArrays(history, turnInput), + new Set(turnInput), + ); // Merged runs are synthetic assemblies, so they are pattern-checked but // never length-checked (`InputValidationOptions.checkMaxLength`). await assertInputTextsValid( diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 07af8f9820..d26fec9c32 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -3,6 +3,8 @@ import { createPrivateTextDecoder, encodePrivateText, PrivateTextEncoder, + privateTextSlice, + privateTextStartsWith, } from "#veryfront/security/private-text.ts"; /** * Agent Runtime - Core execution engine @@ -18,13 +20,13 @@ import { */ import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; -import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { concatPrivateArrays, mapPrivateArray } from "#veryfront/security/private-array.ts"; import { createPrivateReadableStream, enqueuePrivateStream, } from "#veryfront/security/private-stream.ts"; -import { createPrivateDeferred } from "#veryfront/security/private-promise.ts"; +import { chainPrivatePromise, createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { enterSerializedTurn, withRuntimeTurnLineage, @@ -959,12 +961,14 @@ type DeferredRecoveryOutput = function isTextSseChunk(chunk: Uint8Array): boolean { const payload = createPrivateTextDecoder().decode(chunk); - if (!payload.startsWith("data: ")) { + if (!privateTextStartsWith(payload, "data: ")) { return false; } try { - const event = privateJsonParse(payload.slice("data: ".length)) as { type?: unknown }; + const event = privateJsonParse(privateTextSlice(payload, "data: ".length)) as { + type?: unknown; + }; return event.type === "text-start" || event.type === "text-delta" || event.type === "text-end"; } catch { @@ -974,12 +978,14 @@ function isTextSseChunk(chunk: Uint8Array): boolean { function isTextEndSseChunk(chunk: Uint8Array): boolean { const payload = createPrivateTextDecoder().decode(chunk); - if (!payload.startsWith("data: ")) { + if (!privateTextStartsWith(payload, "data: ")) { return false; } try { - const event = privateJsonParse(payload.slice("data: ".length)) as { type?: unknown }; + const event = privateJsonParse(privateTextSlice(payload, "data: ".length)) as { + type?: unknown; + }; return event.type === "text-end"; } catch { return false; @@ -988,12 +994,15 @@ function isTextEndSseChunk(chunk: Uint8Array): boolean { function textDeltaFromSseChunk(chunk: Uint8Array): string | undefined { const payload = createPrivateTextDecoder().decode(chunk); - if (!payload.startsWith("data: ")) { + if (!privateTextStartsWith(payload, "data: ")) { return undefined; } try { - const event = privateJsonParse(payload.slice("data: ".length)) as Record; + const event = privateJsonParse(privateTextSlice(payload, "data: ".length)) as Record< + string, + unknown + >; return event.type === "text-delta" && typeof event.delta === "string" ? event.delta : undefined; } catch { return undefined; @@ -1006,7 +1015,7 @@ function stripLeadingText( ): { text: string; remainingPrefixLength: number } { const consumedLength = Math.min(text.length, remainingPrefixLength); return { - text: text.slice(consumedLength), + text: privateTextSlice(text, consumedLength), remainingPrefixLength: remainingPrefixLength - consumedLength, }; } @@ -1017,12 +1026,15 @@ function stripTextDeltaPrefixFromSseChunk( encoder: TextEncoder, ): { chunk: Uint8Array | undefined; remainingPrefixLength: number } { const payload = createPrivateTextDecoder().decode(chunk); - if (!payload.startsWith("data: ")) { + if (!privateTextStartsWith(payload, "data: ")) { return { chunk, remainingPrefixLength }; } try { - const event = privateJsonParse(payload.slice("data: ".length)) as Record; + const event = privateJsonParse(privateTextSlice(payload, "data: ".length)) as Record< + string, + unknown + >; if (event.type !== "text-delta" || typeof event.delta !== "string") { return { chunk, remainingPrefixLength }; } @@ -1048,12 +1060,15 @@ function rewriteRecoveryTextSseChunkId( encoder: TextEncoder, ): Uint8Array { const payload = createPrivateTextDecoder().decode(chunk); - if (!payload.startsWith("data: ")) { + if (!privateTextStartsWith(payload, "data: ")) { return chunk; } try { - const event = privateJsonParse(payload.slice("data: ".length)) as Record; + const event = privateJsonParse(privateTextSlice(payload, "data: ".length)) as Record< + string, + unknown + >; if ( event.type !== "text-start" && event.type !== "text-delta" && event.type !== "text-end" @@ -1786,7 +1801,10 @@ export class AgentRuntime { const checkpoints = getRuntimeProviderReplayCheckpoints(this.config); if (!checkpoints?.length) return; const history = mapPrivateArray(await this.memory.getMessages(), cloneMessageForCommit); - applyProviderReplayCheckpointsToMessages([...history, ...inputMessages], checkpoints); + applyProviderReplayCheckpointsToMessages( + concatPrivateArrays(history, inputMessages), + checkpoints, + ); } private prepareTurnMessages( @@ -1830,7 +1848,7 @@ export class AgentRuntime { const leaveLineage = enterSerializedTurn(this); const predecessor = this.#turnCommitQueue; - const task = awaitAbortable(predecessor, abortSignal).then(async () => { + const task = chainPrivatePromise(awaitAbortable(predecessor, abortSignal), async () => { try { throwIfAborted(abortSignal); const prepared = await this.#commitTurnMessages(inputMessages, context); @@ -1859,13 +1877,10 @@ export class AgentRuntime { leaveLineage(); throw error; }); - const finalized = task.then( - ({ finalized }) => finalized, - () => undefined, - ); + const finalized = chainPrivatePromise(task, ({ finalized }) => finalized, () => undefined); // Cancellation releases this caller, not the preceding turn's queue slot. // Later turns must still wait until that predecessor has finalized. - this.#turnCommitQueue = Promise.all([predecessor, finalized]).then(() => undefined); + this.#turnCommitQueue = chainPrivatePromise(predecessor, () => finalized); return task; } @@ -1897,7 +1912,7 @@ export class AgentRuntime { const commit = async (): Promise => { if (rejection) throw rejection.error; if (transaction === undefined) return; - finalization ??= transaction.then(async (prepared) => { + finalization ??= chainPrivatePromise(transaction, async (prepared) => { try { await prepared.commit(); } catch (error) { @@ -1913,10 +1928,14 @@ export class AgentRuntime { if (finalization !== undefined) { // The original caller observes commit or rollback errors. Cleanup must // still finish so streaming can report that error and close replay state. - await finalization.catch(() => undefined); + await chainPrivatePromise(finalization, () => undefined, () => undefined); return; } - finalization = transaction.catch(() => undefined).then((prepared) => prepared?.rollback()); + finalization = chainPrivatePromise( + transaction, + (prepared) => prepared.rollback(), + () => undefined, + ); await finalization; }; const persistence = { @@ -1929,7 +1948,7 @@ export class AgentRuntime { context, abortSignal, ); - return transaction.then(({ messages }) => messages); + return chainPrivatePromise(transaction, ({ messages }) => messages); }, commit, addMessage: async (message: Message) => { @@ -2005,7 +2024,7 @@ export class AgentRuntime { try { if (validateTurnMessages || validateProjectedMessages || validateProviderRequest) { history = await turnMemory.getMessages(); - if (history.length > 0) validated = [...history, ...committedInputMessages]; + if (history.length > 0) validated = concatPrivateArrays(history, committedInputMessages); // Durable provider replay metadata can keep a reasoning-only assistant // turn in the actual provider request. Attach it before validation so // the validator does not incorrectly merge the user turns around it. @@ -2035,7 +2054,9 @@ export class AgentRuntime { return snapshot; }); } - for (const msg of committedInputMessages) await turnMemory.add(msg); + for (let index = 0; index < committedInputMessages.length; index++) { + await turnMemory.add(committedInputMessages[index]!); + } persisted = await turnMemory.getMessages(); if (persisted.length > 0 && !providerTranscriptsEqual(persisted, validated)) { if (validateProjectedMessages) { @@ -2140,7 +2161,7 @@ export class AgentRuntime { providerOptionKey, ), }), - messages: [...messages], + messages: mapPrivateArray(messages, (message) => message), context, }); @@ -2673,7 +2694,7 @@ export class AgentRuntime { const languageModel = resolvedModel ?? resolveModel(effectiveModel); const toolCalls: ToolCall[] = []; - const currentMessages = [...messages]; + const currentMessages = mapPrivateArray(messages, (message) => message); applyProviderReplayCheckpointsToMessages( currentMessages, getRuntimeProviderReplayCheckpoints(this.config), @@ -3354,7 +3375,7 @@ export class AgentRuntime { const languageModel = resolvedModel ?? resolveModel(effectiveModel); const toolCalls: ToolCall[] = []; - const currentMessages = [...messages]; + const currentMessages = mapPrivateArray(messages, (message) => message); applyProviderReplayCheckpointsToMessages( currentMessages, getRuntimeProviderReplayCheckpoints(this.config), @@ -3538,7 +3559,7 @@ export class AgentRuntime { ? `recovery:step:${step}` : `${stepTextPartId}:recovery`; const remainingRecoveryReplayText = () => - previousRecoveryText.slice(suppressedRecoveryReplayTextLength); + privateTextSlice(previousRecoveryText, suppressedRecoveryReplayTextLength); const flushDeferredRecoveryOutput = ( interruptedRecoveryPrefixLength: number, repeatsInterruptedRecoveryText: boolean, @@ -3588,15 +3609,18 @@ export class AgentRuntime { if (deferredRecoveryOutput === undefined || releasedDeferredRecoveryOutput) return; const expectedReplayText = remainingRecoveryReplayText(); - const sseDiverged = !expectedReplayText.startsWith(deferredRecoverySseText); + const sseDiverged = !privateTextStartsWith(expectedReplayText, deferredRecoverySseText); const callbackDiverged = callbacks?.onChunk === undefined || - !expectedReplayText.startsWith(deferredRecoveryCallbackText); + !privateTextStartsWith(expectedReplayText, deferredRecoveryCallbackText); if (!sseDiverged || !callbackDiverged) return; const observedRecoveryText = callbacks?.onChunk === undefined ? deferredRecoverySseText : deferredRecoveryCallbackText; - const extendsPreviousRecoveryText = observedRecoveryText.startsWith(expectedReplayText); + const extendsPreviousRecoveryText = privateTextStartsWith( + observedRecoveryText, + expectedReplayText, + ); flushDeferredRecoveryOutput( extendsPreviousRecoveryText ? expectedReplayText.length : 0, false, @@ -3651,9 +3675,9 @@ export class AgentRuntime { if ( !isTextEndEvent || deferredRecoveryOutput === undefined || releasedDeferredRecoveryOutput || - !remainingRecoveryReplayText().startsWith(deferredRecoverySseText) || + !privateTextStartsWith(remainingRecoveryReplayText(), deferredRecoverySseText) || (callbacks?.onChunk !== undefined && - !remainingRecoveryReplayText().startsWith(deferredRecoveryCallbackText)) + !privateTextStartsWith(remainingRecoveryReplayText(), deferredRecoveryCallbackText)) ) { return; } @@ -3725,15 +3749,16 @@ export class AgentRuntime { throwIfAborted(abortSignal); const interruptedRecoveryPrefixLength = deferredRecoveryOutput === undefined ? 0 - : state.accumulatedText.startsWith(previousRecoveryText) + : privateTextStartsWith(state.accumulatedText, previousRecoveryText) ? previousRecoveryText.length - : previousRecoveryText.startsWith(state.accumulatedText) + : privateTextStartsWith(previousRecoveryText, state.accumulatedText) ? state.accumulatedText.length : 0; const recoveryPresentationPrefixLength = suppressedRecoveryReplayTextLength > 0 ? suppressedRecoveryReplayTextLength : interruptedRecoveryPrefixLength; - const recoveryPresentationText = state.accumulatedText.slice( + const recoveryPresentationText = privateTextSlice( + state.accumulatedText, recoveryPresentationPrefixLength, ); const repeatsInterruptedRecoveryText = interruptedRecoveryPrefixLength > 0 && @@ -3863,7 +3888,7 @@ export class AgentRuntime { } else if ( step === interruptedLocalToolBatchRecoveryStep && interruptedRecoveryPrefixLength > 0 ) { - latestAssistantText = previousRecoveryText.startsWith(state.accumulatedText) + latestAssistantText = privateTextStartsWith(previousRecoveryText, state.accumulatedText) ? previousRecoveryText : state.accumulatedText; } else if ( diff --git a/src/agent/runtime/skill-policy-enforcement.ts b/src/agent/runtime/skill-policy-enforcement.ts index f37583a76d..01df977926 100644 --- a/src/agent/runtime/skill-policy-enforcement.ts +++ b/src/agent/runtime/skill-policy-enforcement.ts @@ -145,7 +145,8 @@ export function hydrateActiveSkillStateFromMessages( activeSkillDelegationOverrides: undefined, }; - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; for (const part of message.parts) { if (!isToolResultPart(part) || part.toolName !== LOAD_SKILL_TOOL_ID) continue; state = applySkillActivationResult(state, part.result); diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index b7b6bb702a..ae1d0779a7 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -451,7 +451,8 @@ export function getProviderSendableAssistantMessages( ): ReadonlySet { const sendable = new Set(); const providerExecutedToolCallIds = new Set(); - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; if (message.role === "user" || message.role === "system") { providerExecutedToolCallIds.clear(); } @@ -487,7 +488,8 @@ export function getProviderSendableToolMessages( ): ReadonlySet { const sendable = new Set(); const providerExecutedToolCallIds = new Set(); - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; if (message.role === "user" || message.role === "system") { providerExecutedToolCallIds.clear(); } @@ -527,7 +529,8 @@ export function getAnthropicCompactedAssistantMessages( ); const providerExecutedToolCallIds = new Set(); - for (const [index, message] of messages.entries()) { + for (let index = 0; index < messages.length; index++) { + const message = messages[index]!; if (message.role === "user" || message.role === "system") { providerExecutedToolCallIds.clear(); } @@ -723,7 +726,8 @@ export function convertToTextGenerationRuntimeMessages( const textGenerationRuntimeMessages: TextGenerationRuntimeMessage[] = []; const providerExecutedToolCallIds = new Set(); - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; if (message.role === "user" || message.role === "system") { providerExecutedToolCallIds.clear(); } diff --git a/src/security/private-array.test.ts b/src/security/private-array.test.ts new file mode 100644 index 0000000000..6f189307ae --- /dev/null +++ b/src/security/private-array.test.ts @@ -0,0 +1,22 @@ +import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { concatPrivateArrays } from "./private-array.ts"; + +describe("private array concatenation", () => { + it("preserves order, element identity, sparse positions, and the source arrays", () => { + const first = { value: "first" }; + const last = { value: "last" }; + const left = [first, , first]; + const right = [, last]; + const joined = concatPrivateArrays(left, right); + assertEquals(joined.length, 5); + assertEquals(Object.keys(joined), ["0", "2", "4"]); + assertStrictEquals(joined[0], first); + assertStrictEquals(joined[2], first); + assertStrictEquals(joined[4], last); + assertEquals(left.length, 3); + assertEquals(Object.keys(left), ["0", "2"]); + assertEquals(right.length, 2); + assertEquals(Object.keys(right), ["1"]); + }); +}); diff --git a/src/security/private-array.ts b/src/security/private-array.ts index a249ef96c0..1cafbb00eb 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -2,6 +2,21 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts" const hasOwn = Object.hasOwn; +/** Join private arrays through own indexed elements, preserving sparse positions. */ +export function concatPrivateArrays(left: readonly T[], right: readonly T[]): T[] { + const output = mapPrivateArray(left, (value) => value); + output.length = left.length + right.length; + for (let index = 0; index < right.length; index++) { + if (!hasOwn(right, index)) continue; + defineOwnDataProperty(output, left.length + index, right[index], { + enumerable: true, + configurable: true, + writable: true, + }); + } + return output; +} + /** Map private arrays without consulting caller-visible methods or array species. */ export function mapPrivateArray( values: readonly T[], diff --git a/src/security/private-text.ts b/src/security/private-text.ts index aa74883429..bf6988afb5 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -1,4 +1,14 @@ const apply = Reflect.apply; +const startsWith = String.prototype.startsWith; +const slice = String.prototype.slice; + +export function privateTextStartsWith(value: string, search: string): boolean { + return apply(startsWith, value, [search]) as boolean; +} + +export function privateTextSlice(value: string, start: number, end?: number): string { + return apply(slice, value, [start, end]) as string; +} const NativeTextEncoder = TextEncoder; const NativeTextDecoder = TextDecoder; const encode = NativeTextEncoder.prototype.encode; diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 578446aa01..3ed90e4d79 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -9,7 +9,9 @@ import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpe import type { JsonValue } from "#veryfront/schemas/index.ts"; describe("prepared executor private iteration", () => { - for (const probe of ["async generators", "inherited metadata"]) { + for ( + const probe of ["async generators", "inherited metadata", "array iteration", "promise chaining"] + ) { it(`keeps stream requests and model output out of replaced ${probe}`, async () => { const binding = { allocationId: "iterators", invocationId: "iterators", generation: 1 }; const source = { type: "release", releaseId: "synthetic-release" } as const; @@ -88,11 +90,31 @@ describe("prepared executor private iteration", () => { transport: { readable: forward.readable, writable: backward.writable }, }); const prototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); + const originalArrayIterator = Array.prototype[Symbol.iterator]; + const originalThen = Promise.prototype.then; const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); const originalNext = prototype.next; const originalReturn = prototype.return; let observations = 0; let frames: JsonValue[] = []; + const observeMessages = (value: unknown) => { + const messages = Array.isArray(value) + ? value + : value !== null && typeof value === "object" + ? Object.getOwnPropertyDescriptor(value, "messages")?.value + : undefined; + if (!Array.isArray(messages)) return; + for (let index = 0; index < messages.length; index++) { + const parts = messages[index]?.parts; + if (!Array.isArray(parts)) continue; + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + if (parts[partIndex]?.text === marker) { + observations++; + return; + } + } + } + }; const hook = (original: typeof originalNext) => async function (this: unknown, ...args: unknown[]) { const result = await Reflect.apply(original, this, args) as IteratorResult; @@ -104,6 +126,24 @@ describe("prepared executor private iteration", () => { if (probe === "async generators") { prototype.next = hook(originalNext); prototype.return = hook(originalReturn); + } else if (probe === "array iteration") { + Array.prototype[Symbol.iterator] = (function (this: unknown[]) { + observeMessages(this); + return Reflect.apply(originalArrayIterator, this, []); + }) as typeof originalArrayIterator; + } else if (probe === "promise chaining") { + Promise.prototype.then = (function ( + this: Promise, + fulfilled: ((value: unknown) => unknown) | null | undefined, + rejected: ((reason: unknown) => unknown) | null | undefined, + ) { + return Reflect.apply(originalThen, this, [(value: unknown) => { + observeMessages(value); + return typeof fulfilled === "function" + ? Reflect.apply(fulfilled, undefined, [value]) + : value; + }, rejected]); + }) as typeof originalThen; } else { Object.defineProperty(Object.prototype, "metadata", { configurable: true, @@ -131,6 +171,8 @@ describe("prepared executor private iteration", () => { }], })); } finally { + Array.prototype[Symbol.iterator] = originalArrayIterator; + Promise.prototype.then = originalThen; if (originalMetadata) Object.defineProperty(Object.prototype, "metadata", originalMetadata); else Reflect.deleteProperty(Object.prototype, "metadata"); prototype.next = originalNext; diff --git a/tests/integration/agent/executor-recovery-string-intrinsics.test.ts b/tests/integration/agent/executor-recovery-string-intrinsics.test.ts new file mode 100644 index 0000000000..35d3c4d5fb --- /dev/null +++ b/tests/integration/agent/executor-recovery-string-intrinsics.test.ts @@ -0,0 +1,67 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { agent } from "#veryfront/agent/index.ts"; +import { tool } from "#veryfront/tool"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; + +describe("private SSE recovery strings", () => { + it("recovers a streamed suffix without exposing private text to replaced string methods", async () => { + const marker = "Created the assistant."; + const model = scriptedModel([ + { + parts: [ + { type: "text-delta", text: marker }, + { type: "tool-input-start", id: "synthetic-recovery", toolName: "studio_suggestions" }, + { type: "tool-input-delta", id: "synthetic-recovery", delta: "{}" }, + { type: "finish", finishReason: "tool-calls" }, + ], + }, + { text: `${marker} It is ready.` }, + ]); + const assistant = agent( + { + model: "hosted/synthetic-recovery", + system: "Synthetic recovery instructions", + maxSteps: 3, + __vfToolLoadingMode: "eager", + resolveModelTransport: () => ({ model }), + tools: { + studio_suggestions: tool({ + id: "studio_suggestions", + description: "Capture synthetic suggestions", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => Promise.resolve({ suggestions: [] }), + }), + }, + } as Parameters[0], + ); + const startsWith = String.prototype.startsWith; + const slice = String.prototype.slice; + const includes = String.prototype.includes; + let observations = 0; + const chunks: string[] = []; + try { + String.prototype.startsWith = function (search, position) { + if (Reflect.apply(includes, this, [marker])) observations++; + return Reflect.apply(startsWith, this, [search, position]); + }; + String.prototype.slice = function (start, end) { + if (Reflect.apply(includes, this, [marker])) observations++; + return Reflect.apply(slice, this, [start, end]); + }; + const response = await assistant.stream({ + input: "Create an assistant", + onChunk: (text) => chunks.push(text), + }); + await response.toDataStreamResponse().text(); + } finally { + String.prototype.startsWith = startsWith; + String.prototype.slice = slice; + } + assertEquals(model.callCount, 2); + assertEquals(chunks, [marker, " It is ready."]); + assertEquals(observations, 0); + }); +}); From 4ffc114ad0e6ae3675870694a8724a887800fa96 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 22:39:03 +0200 Subject: [PATCH 118/194] fix(agent): validate effective thinking limits before facade setup --- src/agent/hosted/executor-model-grant.ts | 10 +- .../hosted/executor-runtime-prepare.test.ts | 65 ++++++++++++- src/agent/hosted/executor-runtime-prepare.ts | 41 +++++--- .../executor-recovery-text-intrinsics.test.ts | 94 +++++++++++++++++++ 4 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 tests/integration/agent/executor-recovery-text-intrinsics.test.ts diff --git a/src/agent/hosted/executor-model-grant.ts b/src/agent/hosted/executor-model-grant.ts index 157430ee51..f9319c7fa0 100644 --- a/src/agent/hosted/executor-model-grant.ts +++ b/src/agent/hosted/executor-model-grant.ts @@ -14,6 +14,8 @@ import { parseExecutorModelData, } from "./executor-model-schema.ts"; +const numberIsSafeInteger = Number.isSafeInteger; + type ProviderTool = Extract; /** Broker-owned policy. No default quota or API billing authority is implied. */ @@ -77,7 +79,9 @@ function assertSingleCompletion(options: ExecutorModelDispatch["options"]): void /** Internal output allowance reserved by the effective provider thinking configuration. */ export function getExecutorModelAdditiveReasoningTokens( - request: Pick, + request: Pick & { + options: Pick; + }, ): number { if (resolveModelCallProvider(request.model) !== "anthropic") return 0; const reasoning = buildModelCallContextRequest(request.model, request.options)?.reasoning; @@ -91,7 +95,7 @@ export function getExecutorModelAdditiveReasoningTokens( ? 32768 : 4096); // Match the first-party Anthropic builder; its offline contract matrix guards drift. - if (!Number.isSafeInteger(budget) || budget < 1024) { + if (!numberIsSafeInteger(budget) || budget < 1024) { throw createExecutorModelFailure("RESOURCE_LIMIT_EXCEEDED"); } return budget; @@ -107,7 +111,7 @@ export function getExecutorModelAdditiveReasoningTokens( (thinking as Record).type === "enabled" ) { const budget = reasoning?.budgetTokens; - if (budget === undefined || !Number.isSafeInteger(budget) || budget < 1024) { + if (budget === undefined || !numberIsSafeInteger(budget) || budget < 1024) { throw createExecutorModelFailure("RESOURCE_LIMIT_EXCEEDED"); } return budget; diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 859a30cfa3..6ef744572a 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -15,7 +15,7 @@ import type { ToolDefinition, } from "#veryfront/tool"; import { registerModelRuntimeResolverRevoker } from "#veryfront/agent/runtime/model-transport.ts"; -import { createExecutorModelAdmission } from "./executor-model-grant.ts"; +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 type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; @@ -603,6 +603,69 @@ function syntheticRemoteTool(name: string): ToolDefinition { } describe("executor runtime preparation review regressions", () => { + const outputCases: { + name: string; + request: Record; + expected?: number; + ceiling?: number; + }[] = [ + { name: "catalog thinking default", request: {}, expected: 6144 }, + { name: "neutral thinking default", request: { thinking: { enabled: true } }, expected: 4096 }, + { name: "explicit narrower output", request: { maxOutputTokens: 1000 }, expected: 1000 }, + { name: "explicit output exceeding the remainder", request: { maxOutputTokens: 6145 } }, + { name: "no room after thinking", request: {}, ceiling: 2048 }, + { + name: "invalid fixed thinking budget", + request: { thinking: { enabled: true, budgetTokens: 512 } }, + }, + ]; + for (const test of outputCases) { + it(`reserves reasoning tokens during preparation: ${test.name}`, async () => { + const selectedModel = "veryfront-cloud/anthropic/claude-sonnet-4-6"; + let captured: ModelRuntimeCallOptions | undefined; + let resolutions = 0; + const f = fixture({ + grant: { + ...grant, + defaultModelId: selectedModel, + models: new Map([[selectedModel, { + maxOutputTokens: test.ceiling ?? 8192, + providerToolNames: [], + }]]), + }, + facades: { + resolveModelRuntime: () => { + resolutions++; + return { + ...model, + provider: "anthropic", + modelId: "claude-sonnet-4-6", + doStream(options) { + captured = options as ModelRuntimeCallOptions; + return finishStream(); + }, + }; + }, + }, + }); + try { + const request = { agentId: "coder", ...test.request }; + if (test.expected === undefined) { + assertEquals(await prepare(f.owner, request), { + ok: false, + code: "EXECUTOR_RUNTIME_NOT_GRANTED", + }); + assertEquals(resolutions, 0); + } else { + await Array.fromAsync(await preparedStream(f, request)); + assertEquals(captured?.maxOutputTokens, test.expected); + } + } finally { + await f.owner.close(); + } + }); + } + it("retains the steering preparation method and its original receiver through discovery", async () => { class Steering { #calls: string[] = []; diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index a6dbeb9fc4..f347b2e3dd 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -9,10 +9,10 @@ import { } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { - getVeryfrontCloudProviderFromModelId, 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"; @@ -101,6 +101,7 @@ 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; @@ -455,17 +456,33 @@ export function createExecutorRuntimePreparation(input: Options) { ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); const thinking = request.thinking ?? definition.thinking ?? resolveVeryfrontCloudModelThinking(modelId); - const reasoning = resolveVeryfrontCloudReasoningOption(modelId, thinking); - const providerOptions = resolveVeryfrontCloudThinkingProviderOptions(modelId, thinking); - const reasoningBudget = getExecutorModelAdditiveReasoningTokens({ - model: { id: modelId, modelId, provider: getVeryfrontCloudProviderFromModelId(modelId) }, - options: { prompt: [], reasoning, providerOptions }, - }); - const availableOutputTokens = modelGrant.maxOutputTokens - reasoningBudget; + 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 ( - availableOutputTokens <= 0 || - (request.maxOutputTokens !== undefined && request.maxOutputTokens > availableOutputTokens) - ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + !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 @@ -612,7 +629,7 @@ export function createExecutorRuntimePreparation(input: Options) { definition.maxSteps ?? grant.maxSteps, grant.maxSteps, ), - maxOutputTokens: request.maxOutputTokens ?? availableOutputTokens, + maxOutputTokens, allowedTools: allowedToolNames, allowedProviderTools: providerToolNames, availableSkillIds: skills.allowedSkillIds, diff --git a/tests/integration/agent/executor-recovery-text-intrinsics.test.ts b/tests/integration/agent/executor-recovery-text-intrinsics.test.ts new file mode 100644 index 0000000000..843158a511 --- /dev/null +++ b/tests/integration/agent/executor-recovery-text-intrinsics.test.ts @@ -0,0 +1,94 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import type { AgentResponse } from "#veryfront/agent/types.ts"; +import type { RuntimeToolFilterConfig } from "#veryfront/agent/runtime/runtime-tool-config.ts"; +import { runtimeStream } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import type { ModelRuntime } from "#veryfront/provider/types.ts"; +import { tool } from "#veryfront/tool"; +import { defineSchema } from "#veryfront/schemas/index.ts"; + +for (const replaceMethods of [false, true]) { + describe(`private recovery text ${replaceMethods ? "hooks" : "baseline"}`, () => { + it("deduplicates replayed text without invoking replaced string methods", async () => { + const marker = "synthetic-private-recovery-marker"; + const originalStartsWith = String.prototype.startsWith; + const originalSlice = String.prototype.slice; + const originalIncludes = String.prototype.includes; + const apply = Reflect.apply; + let calls = 0; + let observations = 0; + let finished: AgentResponse | undefined; + const chunks: string[] = []; + const model: ModelRuntime = { + provider: "openai", + modelId: "gpt-5.4", + doGenerate: () => Promise.reject(new Error("Unexpected generation")), + doStream: () => + Promise.resolve({ + stream: runtimeStream( + ++calls === 2 + ? [ + { type: "text-delta", text: marker + " " }, + { type: "finish", finishReason: "stop" }, + ] + : [ + { type: "text-delta", text: marker + " complete" }, + { + type: "tool-input-start", + id: "synthetic-recovery", + toolName: "studio_suggestions", + }, + { type: "tool-input-delta", id: "synthetic-recovery", delta: "{}" }, + { type: "finish", finishReason: "tool-calls" }, + ], + ), + }), + }; + const runtime = createEphemeralAgentWithRuntimeOptions({ + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic recovery instructions", + maxSteps: 3, + __vfToolLoadingMode: "eager", + tools: { + studio_suggestions: tool({ + id: "studio_suggestions", + description: "Synthetic suggestions", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => Promise.resolve({ suggestions: [] }), + }), + }, + } as RuntimeToolFilterConfig, { resolveModelRuntime: () => model }); + try { + if (replaceMethods) { + String.prototype.startsWith = function (search, position) { + if (apply(originalIncludes, this, [marker])) observations++; + return apply(originalStartsWith, this, [search, position]); + }; + String.prototype.slice = function (start, end) { + if (apply(originalIncludes, this, [marker])) observations++; + return apply(originalSlice, this, [start, end]); + }; + } + const result = await runtime.stream({ + input: "Synthetic request", + onChunk: (chunk) => chunks.push(chunk), + onFinish: (response) => { + finished = response; + }, + }); + await result.toDataStreamResponse().text(); + } finally { + if (replaceMethods) { + String.prototype.startsWith = originalStartsWith; + String.prototype.slice = originalSlice; + } + } + assertEquals(calls, 2); + assertEquals(chunks, [marker + " complete"]); + assertEquals((finished as AgentResponse | undefined)?.text, marker + " complete"); + assertEquals(observations, 0); + }); + }); +} From 239b87b2f7560c85bd1d8edab43d2b06c557a5da Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 22:49:22 +0200 Subject: [PATCH 119/194] fix(agent): scan post-form messages through own indexed data --- src/agent/runtime/skill-policy-enforcement.ts | 30 ++++++--- src/agent/runtime/skill-policy.test.ts | 64 +++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/agent/runtime/skill-policy-enforcement.ts b/src/agent/runtime/skill-policy-enforcement.ts index 01df977926..b42b090067 100644 --- a/src/agent/runtime/skill-policy-enforcement.ts +++ b/src/agent/runtime/skill-policy-enforcement.ts @@ -28,6 +28,8 @@ import { import { isGenuineUserTurnMessage } from "./runtime-message-origin.ts"; const logger = serverLogger.component("agent"); +const objectHasOwn = Object.hasOwn; +const arrayIsArray = Array.isArray; export const LOAD_SKILL_TOOL_ID = "load_skill"; export const FORM_INPUT_TOOL_ID = "form_input"; @@ -48,7 +50,7 @@ const POST_SUBMITTED_FORM_INPUT_BLOCKED_TOOL_IDS: ReadonlySet = new Set( function isRecord(value: unknown): value is Record { try { - return value !== null && typeof value === "object" && !Array.isArray(value); + return value !== null && typeof value === "object" && !arrayIsArray(value); } catch { return false; } @@ -56,7 +58,7 @@ function isRecord(value: unknown): value is Record { function getBoundedArrayLength(value: unknown, maxEntries: number): number | null { try { - if (!Array.isArray(value)) return null; + if (!arrayIsArray(value)) return null; } catch { return null; } @@ -265,7 +267,8 @@ export function isSubmittedFormInputResult(result: unknown): boolean { if (submitted === UNREADABLE_TOOL_RESULT_PROPERTY) return false; if (submitted !== undefined) return submitted === true; - for (const wrapperName of ["response", "output"]) { + for (let index = 0; index < 2; index++) { + const wrapperName = index === 0 ? "response" : "output"; const wrapper = readToolResultOwnDataProperty(normalized, wrapperName); if (!isRecord(wrapper) || hasToolExecutionErrorMarker(wrapper)) continue; const wrappedSubmitted = readToolResultOwnDataProperty(wrapper, "submitted"); @@ -278,6 +281,7 @@ export function isSubmittedFormInputResult(result: unknown): boolean { function latestUserMessageIndex(messages: readonly Message[]): number { for (let index = messages.length - 1; index >= 0; index--) { + if (!objectHasOwn(messages, index)) continue; if (messages[index] && isGenuineUserTurnMessage(messages[index]!)) { return index; } @@ -289,13 +293,19 @@ function latestUserMessageIndex(messages: readonly Message[]): number { export function hasSubmittedFormInputResult(messages: readonly Message[]): boolean { const startIndex = latestUserMessageIndex(messages) + 1; - return messages.slice(startIndex).some((message) => - message.parts.some((part) => - isToolResultPart(part) && - part.toolName === FORM_INPUT_TOOL_ID && - isSubmittedFormInputResult(part.result) - ) - ); + for (let index = startIndex; index < messages.length; index++) { + if (!objectHasOwn(messages, index)) continue; + const parts = messages[index]!.parts; + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + if (!objectHasOwn(parts, partIndex)) continue; + const part = parts[partIndex]!; + if ( + isToolResultPart(part) && part.toolName === FORM_INPUT_TOOL_ID && + isSubmittedFormInputResult(part.result) + ) return true; + } + } + return false; } export function filterToolsAfterSubmittedFormInput( diff --git a/src/agent/runtime/skill-policy.test.ts b/src/agent/runtime/skill-policy.test.ts index e73796d4c8..2a715f875f 100644 --- a/src/agent/runtime/skill-policy.test.ts +++ b/src/agent/runtime/skill-policy.test.ts @@ -18,6 +18,70 @@ import { import { markRuntimeGeneratedUserMessage } from "./runtime-message-origin.ts"; describe("src/agent/runtime skill policy helpers", () => { + it("scans submitted forms without invoking overridden array methods", () => { + const parts: Message["parts"] = [{ + type: "tool-result", + toolCallId: "synthetic-form", + toolName: "form_input", + result: { submitted: true }, + }]; + const messages: Message[] = [ + { + id: "synthetic-user", + role: "user", + parts: [{ type: "text", text: "Synthetic private request" }], + }, + { id: "synthetic-result", role: "tool", parts }, + ]; + let reads = 0; + Object.defineProperty(messages, "slice", { + get() { + reads++; + return Array.prototype.slice; + }, + }); + Object.defineProperty(parts, "some", { + get() { + reads++; + return Array.prototype.some; + }, + }); + assertEquals(hasSubmittedFormInputResult(messages), true); + assertEquals(reads, 0); + }); + + it("ignores inherited message entries when locating the active turn and form results", () => { + const messages: Message[] = [{ + id: "synthetic-user", + role: "user", + parts: [{ type: "text", text: "Synthetic private request" }], + }]; + messages.length = 2; + let reads = 0; + Object.setPrototypeOf( + messages, + Object.create(Array.prototype, { + 1: { + get() { + reads++; + return { + id: "inherited-result", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: "inherited", + toolName: "form_input", + result: { submitted: true }, + }], + }; + }, + }, + }), + ); + assertEquals(hasSubmittedFormInputResult(messages), false); + assertEquals(reads, 0); + }); + describe("enforceSkillPolicy", () => { it("should allow any tool when no policy is active", () => { const result = enforceSkillPolicy("Read"); From fdd6afec23b8e6ff169fffdbf3d597dc167c69a8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 23:05:51 +0200 Subject: [PATCH 120/194] fix(agent): append private runtime data through owned array elements --- src/agent/runtime/chat-stream-handler.test.ts | 8 ++ src/agent/runtime/chat-stream-handler.ts | 13 ++-- src/agent/runtime/index.ts | 78 ++++++++++--------- src/agent/runtime/message-adapter.ts | 36 +++++---- ...xt-generation-runtime-message-converter.ts | 27 +++---- src/security/private-array.test.ts | 51 +++++++++++- src/security/private-array.ts | 26 +++++++ .../executor-iterator-intrinsics.test.ts | 15 +++- 8 files changed, 181 insertions(+), 73 deletions(-) diff --git a/src/agent/runtime/chat-stream-handler.test.ts b/src/agent/runtime/chat-stream-handler.test.ts index 4dbae15419..cba41d9d07 100644 --- a/src/agent/runtime/chat-stream-handler.test.ts +++ b/src/agent/runtime/chat-stream-handler.test.ts @@ -368,6 +368,13 @@ describe("chat-stream-handler", () => { it("accumulates streamed reasoning text with Anthropic signatures", async () => { const { events, controller, encoder } = createSSECollector(); const state = createStreamState(); + let appendLookups = 0; + Object.defineProperty(state.reasoningParts, "push", { + get() { + appendLookups++; + return Array.prototype.push; + }, + }); const result = createMockResult([ { type: "reasoning-start", id: "thinking-0" }, @@ -378,6 +385,7 @@ describe("chat-stream-handler", () => { await processStream(result, state, controller, encoder, "text-1", undefined); + assertEquals(appendLookups, 0); assertEquals(state.reasoningParts, [{ id: "thinking-0", text: "Check evidence.", diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 8397343c16..6176c6fde9 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,3 +1,4 @@ +import { pushPrivateArray } from "#veryfront/security/private-array.ts"; import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { closePrivateStream, @@ -853,7 +854,7 @@ export function processStreamInternal( } suppressedToolCallIds.add(toolCallId); pendingProviderExecutedToolCallIds.delete(toolCallId); - state.suppressedToolCalls.push({ id: toolCallId, name: toolName }); + pushPrivateArray(state.suppressedToolCalls, { id: toolCallId, name: toolName }); }; /** @@ -975,7 +976,7 @@ export function processStreamInternal( if (!reasoningParts.has(reasoningId)) { const part = { id: reasoningId, text: "" }; reasoningParts.set(reasoningId, part); - state.reasoningParts.push(part); + pushPrivateArray(state.reasoningParts, part); } sendSSE(controller, encoder, { type: "reasoning-start", @@ -1307,7 +1308,7 @@ export function processStreamInternal( tc.arguments = mergeToolInputDelta(tc.arguments, typedPart.delta); tc.inputDeltas ??= []; - tc.inputDeltas.push(typedPart.delta); + pushPrivateArray(tc.inputDeltas, typedPart.delta); break; } @@ -1506,7 +1507,7 @@ export function processStreamInternal( // result to live clients, durable history, or continuation input. if (typedPart.preliminary === true) break; if (isError) { - state.toolResults.push({ + pushPrivateArray(state.toolResults, { toolCallId: typedPart.toolCallId, toolName: typedPart.toolName, error: toolResultError, @@ -1523,7 +1524,7 @@ export function processStreamInternal( break; } - state.toolResults.push({ + pushPrivateArray(state.toolResults, { toolCallId: typedPart.toolCallId, toolName: typedPart.toolName, output: toolResultOutput, @@ -1577,7 +1578,7 @@ export function processStreamInternal( error: typedPart.error, input: typedPart.input, }); - state.toolResults.push({ + pushPrivateArray(state.toolResults, { toolCallId: typedPart.toolCallId, toolName: typedPart.toolName, error: typedPart.error, diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index d26fec9c32..d51e7421a8 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1,3 +1,9 @@ +import { + appendPrivateArray, + concatPrivateArrays, + mapPrivateArray, + pushPrivateArray, +} from "#veryfront/security/private-array.ts"; import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; import { createPrivateTextDecoder, @@ -20,7 +26,6 @@ import { */ import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; -import { concatPrivateArrays, mapPrivateArray } from "#veryfront/security/private-array.ts"; import { createPrivateReadableStream, enqueuePrivateStream, @@ -764,7 +769,7 @@ function captureOpaqueProxyCloneFailureFingerprints(): readonly string[] { } catch (error) { const fingerprint = getStructuredCloneFailureFingerprint(error); if (fingerprint !== undefined && !fingerprints.includes(fingerprint)) { - fingerprints.push(fingerprint); + pushPrivateArray(fingerprints, fingerprint); } } } @@ -1089,9 +1094,9 @@ function buildGeneratedAssistantMessage( metadata: { id: string; timestamp: number }, ): Message { const parts: MessagePart[] = []; - if (response.text) parts.push({ type: "text", text: response.text }); + if (response.text) pushPrivateArray(parts, { type: "text", text: response.text }); for (const toolCall of response.toolCalls ?? []) { - parts.push({ + pushPrivateArray(parts, { type: `tool-${toolCall.toolName}`, toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, @@ -1356,7 +1361,7 @@ function applyAgentWriteFinalResponseGuard( guardedTools.length > 0 && !visible.some((tool) => tool.name === TOOL_SEARCH_TOOL_NAME) ) { - visible.push(createToolSearchDefinition()); + pushPrivateArray(visible, createToolSearchDefinition()); } return { ...plan, @@ -2917,7 +2922,7 @@ export class AgentRuntime { id: `msg_${Date.now()}_${step}`, timestamp: Date.now(), }); - currentMessages.push(assistantMessage); + pushPrivateArray(currentMessages, assistantMessage); await persistMessage(assistantMessage); await persistProviderReplayCheckpointAfterTurn({ emission: providerReplayCheckpointEmission, @@ -2937,7 +2942,7 @@ export class AgentRuntime { : generatedToolResult.result, generatedToolResult.providerExecuted === true, ); - currentMessages.push(toolResultMessage); + pushPrivateArray(currentMessages, toolResultMessage); await persistMessage(toolResultMessage); throwIfAborted(abortSignal); }; @@ -2958,13 +2963,13 @@ export class AgentRuntime { status: "error", error, }; - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); const errorMessage = createToolErrorMessage( generatedToolResult.toolCallId, generatedToolResult.toolName, error, ); - currentMessages.push(errorMessage); + pushPrivateArray(currentMessages, errorMessage); await persistMessage(errorMessage); return true; }; @@ -3044,9 +3049,9 @@ export class AgentRuntime { tc.toolName, toolCall.error, ); - currentMessages.push(errorMessage); + pushPrivateArray(currentMessages, errorMessage); await persistMessage(errorMessage); - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); return; } if ( @@ -3076,7 +3081,7 @@ export class AgentRuntime { tc.toolName, search.result, ); - currentMessages.push(toolResultMessage); + pushPrivateArray(currentMessages, toolResultMessage); await persistMessage(toolResultMessage); checkpoint = search.checkpoint; } catch (error) { @@ -3087,9 +3092,9 @@ export class AgentRuntime { tc.toolName, toolCall.error, ); - currentMessages.push(errorMessage); + pushPrivateArray(currentMessages, errorMessage); await persistMessage(errorMessage); - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); return; } await persistToolExposureCheckpointBeforeContinuation({ @@ -3097,7 +3102,7 @@ export class AgentRuntime { persist: persistToolExposureCheckpoint, required: requireToolExposureCheckpointPersistence, }); - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); return; } @@ -3153,7 +3158,7 @@ export class AgentRuntime { : {}), }), ); - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); return; } @@ -3186,9 +3191,9 @@ export class AgentRuntime { }], timestamp: Date.now(), }; - currentMessages.push(errorMessage); + pushPrivateArray(currentMessages, errorMessage); await persistMessage(errorMessage); - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); return; } @@ -3283,7 +3288,7 @@ export class AgentRuntime { tc.toolName, result, ); - currentMessages.push(toolResultMessage); + pushPrivateArray(currentMessages, toolResultMessage); await persistMessage(toolResultMessage); } catch (error) { throwIfAborted(abortSignal); @@ -3300,11 +3305,11 @@ export class AgentRuntime { tc.toolName, toolCall.error, ); - currentMessages.push(errorMessage); + pushPrivateArray(currentMessages, errorMessage); await persistMessage(errorMessage); } - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); }); throwIfAborted(abortSignal); } @@ -3667,7 +3672,7 @@ export class AgentRuntime { } } deferredRecoveryOutput.length = 0; - deferredRecoveryOutput.push(...retainedOutput); + appendPrivateArray(deferredRecoveryOutput, retainedOutput); }; const reconcileDeferredRecoveryTextSegment = ( isTextEndEvent: boolean, @@ -3709,7 +3714,7 @@ export class AgentRuntime { } deferredRecoverySseText += textDeltaFromSseChunk(chunk) ?? ""; const isTextEvent = isTextSseChunk(chunk); - deferredRecoveryOutput.push({ + pushPrivateArray(deferredRecoveryOutput, { kind: "sse", chunk, isTextEvent, @@ -3728,7 +3733,7 @@ export class AgentRuntime { } deferredRecoveryCallbackText += chunk; if (callbacks?.onChunk !== undefined) { - deferredRecoveryOutput.push({ kind: "callback", chunk }); + pushPrivateArray(deferredRecoveryOutput, { kind: "callback", chunk }); } releaseDeferredRecoveryOutputAfterDivergence(); }, @@ -3897,7 +3902,7 @@ export class AgentRuntime { ) { latestAssistantText = stepAssistantText; } - currentMessages.push(assistantMessage); + pushPrivateArray(currentMessages, assistantMessage); await persistMessage(assistantMessage); await persistProviderReplayCheckpointAfterTurn({ emission: providerReplayCheckpointEmission, @@ -3917,7 +3922,7 @@ export class AgentRuntime { : { error: stringifyToolError(toolResult.error) }, toolResult.providerExecuted === true, ); - currentMessages.push(toolResultMessage); + pushPrivateArray(currentMessages, toolResultMessage); await persistMessage(toolResultMessage); currentStepToolResults.set( toolResult.toolCallId, @@ -4068,7 +4073,7 @@ export class AgentRuntime { toolCall.error = matchingResult.error === undefined ? undefined : stringifyToolError(matchingResult.error); - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); if (matchingResult.error === undefined) { if (shouldHideProjectToolAfterAgentWriteSuccess(tc.name)) { @@ -4094,7 +4099,7 @@ export class AgentRuntime { toolCall.status = persistedError === undefined ? "completed" : "error"; toolCall.result = persistedResult.result; toolCall.error = persistedError; - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); if (persistedError === undefined) { if (shouldHideProjectToolAfterAgentWriteSuccess(tc.name)) { agentWriteFinalResponseToolGuardEnabled = true; @@ -4128,7 +4133,7 @@ export class AgentRuntime { args: toolCall.args, }); toolCall.status = "completed"; - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); continue; } @@ -4172,7 +4177,7 @@ export class AgentRuntime { } toolCall.status = "completed"; toolCall.result = search.result; - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); setOtelActiveSpanAttributes({ "tool.search.result_count": search.result.resultCount, "tool.search.loaded_count": search.result.loadedCount, @@ -4184,7 +4189,7 @@ export class AgentRuntime { output: search.result, }); const toolResultMessage = createToolResultMessage(tc.id, tc.name, search.result); - currentMessages.push(toolResultMessage); + pushPrivateArray(currentMessages, toolResultMessage); await persistMessage(toolResultMessage); checkpoint = search.checkpoint; currentStepToolResults.set(tc.id, toolResultMessage.parts[0] as ToolResultPart); @@ -4292,7 +4297,7 @@ export class AgentRuntime { toolCall.result = result; toolCall.error = resultError; toolCall.executionTime = Date.now() - startTime; - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); if (resultError === undefined) { // Track skill policy from successful load_skill results @@ -4328,7 +4333,7 @@ export class AgentRuntime { const toolResultMessage = createToolResultMessage(tc.id, tc.name, result); if (!currentStepToolResults.has(tc.id)) { - currentMessages.push(toolResultMessage); + pushPrivateArray(currentMessages, toolResultMessage); await persistMessage(toolResultMessage); currentStepToolResults.set(tc.id, toolResultMessage.parts[0] as ToolResultPart); } @@ -4354,7 +4359,8 @@ export class AgentRuntime { const unavailableNames = [ ...new Set(state.suppressedToolCalls.map((toolCall) => toolCall.name)), ]; - currentMessages.push( + pushPrivateArray( + currentMessages, markRuntimeGeneratedUserMessage({ id: `runtime_note_${Date.now()}_${step}`, role: "user", @@ -4429,7 +4435,7 @@ export class AgentRuntime { toolCall.status = "error"; toolCall.error = errorStr; if (options.includeInResponse !== false) { - toolCalls.push(toolCall); + pushPrivateArray(toolCalls, toolCall); } if (options.emitSse !== false) { @@ -4447,7 +4453,7 @@ export class AgentRuntime { toolCall.name, errorStr, ); - currentMessages.push(errorMessage); + pushPrivateArray(currentMessages, errorMessage); await persistMessage(errorMessage); } diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index bff98d683d..09c17646e9 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -1,4 +1,8 @@ -import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { + appendPrivateArray, + mapPrivateArray, + pushPrivateArray, +} from "#veryfront/security/private-array.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { getProviderModelMessageSourceId, isRecord } from "#veryfront/chat/conversation.ts"; import { @@ -294,13 +298,13 @@ function convertContentToAgentRuntimeParts( for (const part of message.content) { const convertedPart = convertStructuredPart(part); if (convertedPart) { - parts.push(convertedPart); + pushPrivateArray(parts, convertedPart); } if (part.type === "image" || part.type === "file") { const attachmentReference = createAttachmentReference(part); if (attachmentReference) { - attachmentReferences.push(attachmentReference); + pushPrivateArray(attachmentReferences, attachmentReference); } continue; } @@ -310,7 +314,7 @@ function convertContentToAgentRuntimeParts( ? null : buildAttachmentContextPart(attachmentReferences); if (attachmentContextPart) { - parts.push(attachmentContextPart); + pushPrivateArray(parts, attachmentContextPart); } return parts; @@ -474,20 +478,20 @@ function collectAgentRuntimeProviderContentParts( const textPart = getAgentRuntimeTextPart(part); if (textPart) { - textParts.push(textPart); + pushPrivateArray(textParts, textPart); continue; } const reasoningPart = getAgentRuntimeReasoningPart(part); if (reasoningPart) { - reasoningParts.push(reasoningPart); + pushPrivateArray(reasoningParts, reasoningPart); continue; } if (part.type === "image" || part.type === "file") { const nativePart = toNativeFilePart(part.type, part); if (nativePart) { - fileParts.push(nativePart); + pushPrivateArray(fileParts, nativePart); continue; } } @@ -498,14 +502,14 @@ function collectAgentRuntimeProviderContentParts( toolResultCallId ? toolNamesById.get(toolResultCallId) : undefined, ); if (toolResultPart) { - toolResultParts.push(createToolResultPart(toolResultPart)); + pushPrivateArray(toolResultParts, createToolResultPart(toolResultPart)); continue; } const toolCallPart = getAgentRuntimeToolCallPart(part); if (toolCallPart) { toolNamesById.set(toolCallPart.toolCallId, toolCallPart.toolName); - toolCallParts.push({ + pushPrivateArray(toolCallParts, { type: "tool-call", toolCallId: toolCallPart.toolCallId, toolName: toolCallPart.toolName, @@ -537,7 +541,7 @@ function convertAssistantAgentRuntimePartsToProviderMessages( return; } - providerMessages.push({ role: "assistant", content: [...content] }); + pushPrivateArray(providerMessages, { role: "assistant", content: [...content] }); content.length = 0; }; @@ -546,7 +550,7 @@ function convertAssistantAgentRuntimePartsToProviderMessages( return; } - providerMessages.push({ role: "tool", content: [...toolResults] }); + pushPrivateArray(providerMessages, { role: "tool", content: [...toolResults] }); toolResults.length = 0; }; @@ -560,14 +564,14 @@ function convertAssistantAgentRuntimePartsToProviderMessages( flushAssistantMessage(deferredAssistantContent); } - assistantContent.push(part); + pushPrivateArray(assistantContent, part); pendingToolCallIds.add(part.toolCallId); toolNamesById.set(part.toolCallId, part.toolName); return; } if (pendingToolCallIds.size > 0) { - deferredAssistantContent.push(part); + pushPrivateArray(deferredAssistantContent, part); return; } @@ -577,11 +581,11 @@ function convertAssistantAgentRuntimePartsToProviderMessages( flushAssistantMessage(deferredAssistantContent); } - assistantContent.push(part); + pushPrivateArray(assistantContent, part); }; const pushToolResult = (part: ChatToolResultPart) => { - toolResults.push(part); + pushPrivateArray(toolResults, part); pendingToolCallIds.delete(part.toolCallId); }; @@ -716,7 +720,7 @@ export function convertAgentRuntimeMessagesToProviderMessages( const converted: ProviderModelMessage[] = []; for (const message of messages) { - converted.push(...createProviderMessagesFromAgentRuntimeMessage(message)); + appendPrivateArray(converted, createProviderMessagesFromAgentRuntimeMessage(message)); } return converted; diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index ae1d0779a7..25335187ac 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -1,3 +1,4 @@ +import { appendPrivateArray, pushPrivateArray } from "#veryfront/security/private-array.ts"; /** * Text-Generation Runtime Message Converter * @@ -344,13 +345,13 @@ export function convertToTextGenerationRuntimeMessage( for (const part of msg.parts) { if (part.type === "text" && "text" in part) { - content.push({ type: "text", text: (part as { text: string }).text }); + pushPrivateArray(content, { type: "text", text: (part as { text: string }).text }); continue; } const toolPart = getTextGenerationToolCallPart(part, providerExecutedToolCallIds); if (toolPart) { - content.push({ + pushPrivateArray(content, { type: "tool-call", toolCallId: toolPart.toolCallId, toolName: toolPart.toolName, @@ -363,7 +364,7 @@ export function convertToTextGenerationRuntimeMessage( // Ensure non-empty content (providers need at least empty text for tool-only messages) if (content.length === 0) { - content.push({ type: "text", text: "" }); + pushPrivateArray(content, { type: "text", text: "" }); } const providerMetadata = readAttachedProviderMetadata(msg); @@ -388,7 +389,7 @@ export function convertToTextGenerationRuntimeMessage( const toolResultPart = getTextGenerationToolResultPart(part, toolNamesById); if (toolResultPart) { - content.push(toolResultPart); + pushPrivateArray(content, toolResultPart); } } @@ -595,7 +596,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( return; } - messages.push({ role: "assistant", content: [...content] }); + pushPrivateArray(messages, { role: "assistant", content: [...content] }); content.length = 0; }; @@ -604,7 +605,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( return; } - messages.push({ role: "tool", content: [...toolResults] }); + pushPrivateArray(messages, { role: "tool", content: [...toolResults] }); toolResults.length = 0; }; @@ -620,14 +621,14 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( flushAssistantMessage(deferredAssistantContent); } - assistantContent.push(part); + pushPrivateArray(assistantContent, part); pendingToolCallIds.add(part.toolCallId); toolNamesById.set(part.toolCallId, part.toolName); return; } if (pendingToolCallIds.size > 0) { - deferredAssistantContent.push(part); + pushPrivateArray(deferredAssistantContent, part); return; } @@ -637,7 +638,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( flushAssistantMessage(deferredAssistantContent); } - assistantContent.push(part); + pushPrivateArray(assistantContent, part); }; const pushToolResult = (part: TextGenerationRuntimeToolResultPart) => { @@ -645,7 +646,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( return; } - toolResults.push(part); + pushPrivateArray(toolResults, part); pendingToolCallIds.delete(part.toolCallId); }; @@ -696,7 +697,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( if (isProviderReplayDelivered(message)) { markProviderReplayDelivered(anchorMessage); } - messages.push(anchorMessage); + pushPrivateArray(messages, anchorMessage); } else if (providerMetadata !== undefined) { const splitMetadata = splitAnthropicProviderMetadata( providerMetadata, @@ -760,11 +761,11 @@ export function convertToTextGenerationRuntimeMessages( const previousMessage = textGenerationRuntimeMessages.at(-1); if (previousMessage?.role === "tool" && convertedMessage.role === "tool") { - previousMessage.content.push(...convertedMessage.content); + appendPrivateArray(previousMessage.content, convertedMessage.content); continue; } - textGenerationRuntimeMessages.push(convertedMessage); + pushPrivateArray(textGenerationRuntimeMessages, convertedMessage); } } diff --git a/src/security/private-array.test.ts b/src/security/private-array.test.ts index 6f189307ae..aaedfb55db 100644 --- a/src/security/private-array.test.ts +++ b/src/security/private-array.test.ts @@ -1,8 +1,57 @@ import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { concatPrivateArrays } from "./private-array.ts"; +import { appendPrivateArray, concatPrivateArrays, pushPrivateArray } from "./private-array.ts"; describe("private array concatenation", () => { + it("appends arrays without consulting an overridden iterator", () => { + const value = { text: "Synthetic output" }; + const source = [value]; + let observations = 0; + Object.defineProperty(source, Symbol.iterator, { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }); + const target: typeof source = []; + assertEquals(appendPrivateArray(target, source), 1); + assertStrictEquals(target[0], value); + assertEquals(observations, 0); + }); + + it("appends retained objects without consulting push or inherited index setters", () => { + const part = { text: "" }; + const values: typeof part[] = []; + let observations = 0; + Object.setPrototypeOf( + values, + Object.create(Array.prototype, { + push: { + get() { + observations++; + return Array.prototype.push; + }, + }, + 0: { + set(value: typeof part) { + observations++; + Object.defineProperty(this, "0", { + value, + writable: true, + configurable: true, + enumerable: true, + }); + }, + }, + }), + ); + assertEquals(pushPrivateArray(values, part), 1); + part.text = "Synthetic reasoning"; + assertStrictEquals(values[0], part); + assertEquals(values[0]?.text, "Synthetic reasoning"); + assertEquals(observations, 0); + }); + it("preserves order, element identity, sparse positions, and the source arrays", () => { const first = { value: "first" }; const last = { value: "last" }; diff --git a/src/security/private-array.ts b/src/security/private-array.ts index 1cafbb00eb..274728b071 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -2,6 +2,32 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts" const hasOwn = Object.hasOwn; +/** Append one private value without looking up push or invoking inherited setters. */ +export function pushPrivateArray(values: T[], value: T): number { + defineOwnDataProperty(values, values.length, value, { + enumerable: true, + configurable: true, + writable: true, + }); + return values.length; +} + +/** Append another private array without consulting its iterator or inherited entries. */ +export function appendPrivateArray(values: T[], items: readonly T[]): number { + const offset = values.length; + const length = items.length; + values.length = offset + length; + for (let index = 0; index < length; index++) { + if (!hasOwn(items, index)) continue; + defineOwnDataProperty(values, offset + index, items[index], { + enumerable: true, + configurable: true, + writable: true, + }); + } + return values.length; +} + /** Join private arrays through own indexed elements, preserving sparse positions. */ export function concatPrivateArrays(left: readonly T[], right: readonly T[]): T[] { const output = mapPrivateArray(left, (value) => value); diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 3ed90e4d79..1d6d70c38a 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -10,7 +10,13 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; describe("prepared executor private iteration", () => { for ( - const probe of ["async generators", "inherited metadata", "array iteration", "promise chaining"] + const probe of [ + "async generators", + "inherited metadata", + "array iteration", + "array append", + "promise chaining", + ] ) { it(`keeps stream requests and model output out of replaced ${probe}`, async () => { const binding = { allocationId: "iterators", invocationId: "iterators", generation: 1 }; @@ -91,6 +97,7 @@ describe("prepared executor private iteration", () => { }); const prototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); const originalArrayIterator = Array.prototype[Symbol.iterator]; + const originalPush = Array.prototype.push; const originalThen = Promise.prototype.then; const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); const originalNext = prototype.next; @@ -131,6 +138,11 @@ describe("prepared executor private iteration", () => { observeMessages(this); return Reflect.apply(originalArrayIterator, this, []); }) as typeof originalArrayIterator; + } else if (probe === "array append") { + Array.prototype.push = function (...items) { + observeMessages(this); + return Reflect.apply(originalPush, this, items); + }; } else if (probe === "promise chaining") { Promise.prototype.then = (function ( this: Promise, @@ -172,6 +184,7 @@ describe("prepared executor private iteration", () => { })); } finally { Array.prototype[Symbol.iterator] = originalArrayIterator; + Array.prototype.push = originalPush; Promise.prototype.then = originalThen; if (originalMetadata) Object.defineProperty(Object.prototype, "metadata", originalMetadata); else Reflect.deleteProperty(Object.prototype, "metadata"); From 7940051f0b5155271ebdc55219a9b55a400273ab Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 23:21:19 +0200 Subject: [PATCH 121/194] fix(agent): extract private prompt text through indexed reads --- src/agent/memory/memory-interface.ts | 28 +++++---- src/agent/message-text.test.ts | 60 +++++++++++++++++++ ...xt-generation-runtime-message-converter.ts | 9 ++- src/agent/types.ts | 7 +-- .../executor-iterator-intrinsics.test.ts | 15 +++++ 5 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 src/agent/message-text.test.ts diff --git a/src/agent/memory/memory-interface.ts b/src/agent/memory/memory-interface.ts index a79f07035e..3d3a0e1c10 100644 --- a/src/agent/memory/memory-interface.ts +++ b/src/agent/memory/memory-interface.ts @@ -9,6 +9,9 @@ * avoiding circular dependencies with the main types module. **************************/ +const hasOwn = Object.hasOwn; +const ceil = Math.ceil; + export interface MemoryConfigBase { type: string; maxTokens?: number; @@ -78,21 +81,26 @@ export interface MemoryPersistence { export function getTextFromMemoryParts( parts: Array<{ type: string; text?: string }>, ): string { - return parts - .filter( - (p): p is { type: "text"; text: string } => p.type === "text" && typeof p.text === "string", - ) - .map((p) => p.text) - .join(""); + let text = ""; + for (let index = 0; index < parts.length; index++) { + if (!hasOwn(parts, index)) continue; + const part = parts[index]!; + const value = part.type === "text" ? part.text : undefined; + if (typeof value === "string") text += value; + } + return text; } export function estimateTokens(messages: MinimalMessage[]): number { - const totalChars = messages.reduce((sum, msg) => { + let totalChars = 0; + for (let index = 0; index < messages.length; index++) { + if (!hasOwn(messages, index)) continue; + const msg = messages[index]!; const text = getTextFromMemoryParts( msg.parts as Array<{ type: string; text?: string }>, ); - return sum + text.length; - }, 0); + totalChars += text.length; + } - return Math.ceil(totalChars / 4); + return ceil(totalChars / 4); } diff --git a/src/agent/message-text.test.ts b/src/agent/message-text.test.ts new file mode 100644 index 0000000000..0b7415e69c --- /dev/null +++ b/src/agent/message-text.test.ts @@ -0,0 +1,60 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { getTextFromParts, type MessagePart } from "#veryfront/agent/types.ts"; +import { + estimateTokens, + getTextFromMemoryParts, + type MinimalMessage, +} from "#veryfront/agent/memory/memory-interface.ts"; + +describe("private message text extraction", () => { + it("concatenates only text parts without consulting array methods", () => { + const parts: MessagePart[] = [ + { type: "text", text: "Synthetic " }, + { + type: "tool-result", + toolCallId: "ignored", + toolName: "ignored", + result: { text: "Not prompt text" }, + }, + { type: "text", text: "å🙂" }, + ]; + let reads = 0; + Object.defineProperty(parts, "filter", { + get() { + reads++; + return Array.prototype.filter; + }, + }); + assertEquals(getTextFromParts(parts), "Synthetic å🙂"); + assertEquals(getTextFromMemoryParts(parts), "Synthetic å🙂"); + assertEquals(reads, 0); + }); + + it("ignores inherited parts and estimates tokens without a mutable reducer", () => { + const parts: MessagePart[] = [{ type: "text", text: "Owned" }]; + parts.length = 2; + let reads = 0; + Object.setPrototypeOf( + parts, + Object.create(Array.prototype, { + 1: { + get() { + reads++; + return { type: "text", text: "Injected" }; + }, + }, + }), + ); + const messages: MinimalMessage[] = [{ id: "synthetic", role: "user", parts }]; + Object.defineProperty(messages, "reduce", { + get() { + reads++; + return Array.prototype.reduce; + }, + }); + assertEquals(getTextFromParts(parts), "Owned"); + assertEquals(estimateTokens(messages), 2); + assertEquals(reads, 0); + }); +}); diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 25335187ac..e972cecaef 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -682,7 +682,11 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( flushAssistantMessage(deferredAssistantContent); const providerMetadata = readAttachedProviderMetadata(message); - const assistantMessages = messages.filter((entry) => entry.role === "assistant"); + const assistantMessages: Extract[] = []; + for (let index = 0; index < messages.length; index++) { + const entry = messages[index]; + if (entry?.role === "assistant") pushPrivateArray(assistantMessages, entry); + } if (providerMetadata !== undefined && assistantMessages.length === 1) { assistantMessages[0]!.providerMetadata = providerMetadata; if (isProviderReplayDelivered(message)) { @@ -706,7 +710,8 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( if (splitMetadata === undefined) { throw new TypeError("Provider replay metadata cannot follow a split assistant turn"); } - for (const [index, assistantMessage] of assistantMessages.entries()) { + for (let index = 0; index < assistantMessages.length; index++) { + const assistantMessage = assistantMessages[index]!; assistantMessage.providerMetadata = splitMetadata[index]; if (isProviderReplayDelivered(message)) { markProviderReplayDelivered(assistantMessage); diff --git a/src/agent/types.ts b/src/agent/types.ts index 378e7f1837..72f0692f5a 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -6,7 +6,7 @@ import type { ModelRuntime } from "#veryfront/provider/types.ts"; import type { Tool, ToolExecutionContext } from "#veryfront/tool"; import type { JsonSchema, Schema } from "#veryfront/extensions/schema/index.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors"; -import type { Memory } from "./memory/memory-interface.ts"; +import { getTextFromMemoryParts, type Memory } from "#veryfront/agent/memory/memory-interface.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; // Re-export schema-based types @@ -396,10 +396,7 @@ export type AgentMiddleware = ( // Utility functions for working with message parts and tool calls /** Return text from parts. */ export function getTextFromParts(parts: MessagePart[]): string { - return parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join(""); + return getTextFromMemoryParts(parts); } /** Check whether args is present. */ diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 1d6d70c38a..987b41f11d 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -15,6 +15,7 @@ describe("prepared executor private iteration", () => { "inherited metadata", "array iteration", "array append", + "text extraction", "promise chaining", ] ) { @@ -98,6 +99,7 @@ describe("prepared executor private iteration", () => { const prototype = Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())); const originalArrayIterator = Array.prototype[Symbol.iterator]; const originalPush = Array.prototype.push; + const originalFilter = Array.prototype.filter; const originalThen = Promise.prototype.then; const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); const originalNext = prototype.next; @@ -143,6 +145,18 @@ describe("prepared executor private iteration", () => { observeMessages(this); return Reflect.apply(originalPush, this, items); }; + } else if (probe === "text extraction") { + Array.prototype.filter = function ( + this: unknown[], + callback: (value: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + for (let index = 0; index < this.length; index++) { + const part = this[index] as { type?: unknown; text?: unknown } | null; + if (part?.type === "text" && part?.text === marker) observations++; + } + return Reflect.apply(originalFilter, this, [callback, thisArg]); + } as typeof originalFilter; } else if (probe === "promise chaining") { Promise.prototype.then = (function ( this: Promise, @@ -185,6 +199,7 @@ describe("prepared executor private iteration", () => { } finally { Array.prototype[Symbol.iterator] = originalArrayIterator; Array.prototype.push = originalPush; + Array.prototype.filter = originalFilter; Promise.prototype.then = originalThen; if (originalMetadata) Object.defineProperty(Object.prototype, "metadata", originalMetadata); else Reflect.deleteProperty(Object.prototype, "metadata"); From 5d2f9a6d2f12f69ce2722915c777d630544482f4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 23:27:49 +0200 Subject: [PATCH 122/194] fix(agent): protect prompt validation from replaced array methods --- docs/api-reference/veryfront/extensions.md | 12 +- src/agent/memory/memory-interface.ts | 1 + src/agent/message-text.test.ts | 18 ++ src/agent/middleware/security/validator.ts | 170 ++++++++++++------ ...xt-generation-runtime-message-converter.ts | 10 +- src/security/private-array.test.ts | 37 +++- src/security/private-array.ts | 54 ++++++ .../executor-iterator-intrinsics.test.ts | 44 +++++ 8 files changed, 280 insertions(+), 66 deletions(-) diff --git a/docs/api-reference/veryfront/extensions.md b/docs/api-reference/veryfront/extensions.md index 44f16c0e10..428c2a9acf 100644 --- a/docs/api-reference/veryfront/extensions.md +++ b/docs/api-reference/veryfront/extensions.md @@ -501,12 +501,12 @@ import { estimateTokens } from "veryfront/extensions/distributed/agent-memory-su #### Types -| Name | Description | Source | -| ------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `Memory` | Public API contract for memory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | -| `MemoryConfigBase` | ************************ Memory Interface | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | -| `MemoryStats` | Public API contract for memory stats. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | -| `MinimalMessage` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | +| Name | Description | Source | +| ------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `Memory` | Public API contract for memory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | +| `MemoryConfigBase` | Memory retention settings shared by agent memory backends. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | +| `MemoryStats` | Public API contract for memory stats. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | +| `MinimalMessage` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory-interface.ts) | ### `veryfront/extensions/distributed/cache-support` diff --git a/src/agent/memory/memory-interface.ts b/src/agent/memory/memory-interface.ts index 3d3a0e1c10..537c45388f 100644 --- a/src/agent/memory/memory-interface.ts +++ b/src/agent/memory/memory-interface.ts @@ -12,6 +12,7 @@ const hasOwn = Object.hasOwn; const ceil = Math.ceil; +/** Memory retention settings shared by agent memory backends. */ export interface MemoryConfigBase { type: string; maxTokens?: number; diff --git a/src/agent/message-text.test.ts b/src/agent/message-text.test.ts index 0b7415e69c..82602afd88 100644 --- a/src/agent/message-text.test.ts +++ b/src/agent/message-text.test.ts @@ -8,6 +8,24 @@ import { } from "#veryfront/agent/memory/memory-interface.ts"; describe("private message text extraction", () => { + it("concatenates text in order and skips non-text parts and sparse positions", () => { + const parts: MessagePart[] = [ + { type: "text", text: "First" }, + { + type: "file", + filename: "example.txt", + mediaType: "text/plain", + url: "https://example.com/file", + }, + { type: "text", text: "" }, + { type: "text", text: " second" }, + ]; + parts.length = 6; + parts[5] = { type: "text", text: "." }; + assertEquals(getTextFromParts(parts), "First second."); + assertEquals(getTextFromParts([]), ""); + }); + it("concatenates only text parts without consulting array methods", () => { const parts: MessagePart[] = [ { type: "text", text: "Synthetic " }, diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 77be775090..1b12703b7e 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,4 +1,10 @@ -import { concatPrivateArrays, mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { + concatPrivateArrays, + filterPrivateArray, + flatMapPrivateArray, + joinPrivateArray, + mapPrivateArray, +} from "#veryfront/security/private-array.ts"; import { isDeepStrictEqual } from "node:util"; import type { AgentContext, @@ -458,7 +464,7 @@ function extractMessageInputText(message: Message): string[] { } function extractMessageInputTextRegardlessOfRole(message: Message): string[] { - return message.parts.flatMap(extractPartInputText); + return flatMapPrivateArray(message.parts, extractPartInputText); } /** The assembled forms of one message's text parts, if it has more than one. */ @@ -484,7 +490,10 @@ function extractMessageAssembledTextsRegardlessOfRole(message: Message): string[ } const assembled = [ - ...ASSEMBLED_TEXT_SEPARATORS.map((separator) => textParts.join(separator)), + ...mapPrivateArray( + ASSEMBLED_TEXT_SEPARATORS, + (separator) => joinPrivateArray(textParts, separator), + ), ...attachmentMetadata, ]; if (message.role === "user" && buildAttachmentContextFromParts(message.parts)) { @@ -578,7 +587,9 @@ function extractAdjacentRuns( continue; } if (isEmptyText(message)) continue; - if (dropWhitespaceOnly && messageTextParts(message).join("").trim().length === 0) continue; + if (dropWhitespaceOnly && joinPrivateArray(messageTextParts(message), "").trim().length === 0) { + continue; + } run.push(message); } flushRun(); @@ -587,7 +598,7 @@ function extractAdjacentRuns( } function messageTextParts(message: Message, includeAttachments = true): string[] { - const texts = message.parts.filter(isTextPart).map((part) => part.text); + const texts = mapPrivateArray(filterPrivateArray(message.parts, isTextPart), (part) => part.text); const attachmentContext = includeAttachments && message.role === "user" ? buildAttachmentContextFromParts(message.parts).trimStart() : ""; @@ -596,7 +607,7 @@ function messageTextParts(message: Message, includeAttachments = true): string[] } function isEmptyText(message: Message): boolean { - return messageTextParts(message).join("").length === 0; + return joinPrivateArray(messageTextParts(message), "").length === 0; } /** @@ -634,7 +645,8 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { // Anthropic retains whitespace-only system layers and joins each layer with // a blank line. It drops only system layers whose assembled text is empty. - const hoisted = messages.filter( + const hoisted = filterPrivateArray( + messages, (message) => message.role === "system" && !isEmptyText(message), ); // A single system message is covered by the per-message extraction. @@ -732,9 +744,13 @@ function extractMergedRunTexts( // concatenation of the run is assembled as well. for (const runSeparator of ASSEMBLED_TEXT_SEPARATORS) { runTexts.add( - run - .map((message) => messageTextParts(message).join(partSeparator)) - .join(runSeparator), + joinPrivateArray( + mapPrivateArray( + run, + (message) => joinPrivateArray(messageTextParts(message), partSeparator), + ), + runSeparator, + ), ); } } @@ -756,9 +772,9 @@ function providerSystemMessages(system: AgentSystem): Message[] { function extractInputValidationTexts(input: AgentContext["input"]): InputValidationTexts { if (typeof input === "string") return { texts: [input], assembled: [] }; return { - texts: input.flatMap(extractMessageInputText), + texts: flatMapPrivateArray(input, extractMessageInputText), assembled: [ - ...input.flatMap(extractMessageAssembledTexts), + ...flatMapPrivateArray(input, extractMessageAssembledTexts), ...extractMergedRunTexts(input), ], }; @@ -839,14 +855,17 @@ function sanitizeStructuredInput(validator: InputValidator, messages: Message[]) // When any assembled form still changes under sanitization, the part // boundary itself is hiding the payload, so the text parts are collapsed // into one fully sanitized part instead of being kept apart. - const textValues = parts.filter(isTextPart).map((part) => part.text); + const textValues = mapPrivateArray(filterPrivateArray(parts, isTextPart), (part) => part.text); const assembledNeedsRewrite = textValues.length > 1 && ASSEMBLED_TEXT_SEPARATORS.some((separator) => { - const assembled = textValues.join(separator); + const assembled = joinPrivateArray(textValues, separator); return (validator.sanitize(assembled) ?? assembled) !== assembled; }); if (assembledNeedsRewrite) { - parts = collapseTextParts(parts, sanitizeTextToFixpoint(validator, textValues.join(""))); + parts = collapseTextParts( + parts, + sanitizeTextToFixpoint(validator, joinPrivateArray(textValues, "")), + ); messageChanged = true; } @@ -900,9 +919,13 @@ function sanitizeMergedRuns(validator: InputValidator, messages: Message[]): Mes let unsafeAssembly: string | undefined; for (const partSeparator of ASSEMBLED_TEXT_SEPARATORS) { for (const runSeparator of ASSEMBLED_TEXT_SEPARATORS) { - const assembled = run - .map((message) => messageTextParts(message, false).join(partSeparator)) - .join(runSeparator); + const assembled = joinPrivateArray( + mapPrivateArray( + run, + (message) => joinPrivateArray(messageTextParts(message, false), partSeparator), + ), + runSeparator, + ); if ((validator.sanitize(assembled) ?? assembled) !== assembled) { unsafeAssembly = assembled; break; @@ -939,14 +962,20 @@ async function validateInputTexts( options?: InputValidationOptions, ): Promise<{ valid: boolean; violations: SecurityViolation[] }> { const results = await Promise.all([ - ...values.texts.map((value) => validator.validate(value, options)), - ...values.assembled.map((value) => - validator.validate(value, { ...options, checkMaxLength: false, checkCustomValidation: false }) + ...mapPrivateArray(values.texts, (value) => validator.validate(value, options)), + ...mapPrivateArray( + values.assembled, + (value) => + validator.validate(value, { + ...options, + checkMaxLength: false, + checkCustomValidation: false, + }), ), ]); return { valid: results.every((result) => result.valid), - violations: results.flatMap((result) => result.violations), + violations: flatMapPrivateArray(results, (result) => result.violations), }; } @@ -1123,7 +1152,7 @@ function createTrustedMatchPredicate( const choices = [character + escaped + excludePosition(lower) + excludePosition(upper)]; if (word(segment.text[0]) === (escaped === "b")) choices.push(atPosition(lower)); if (word(segment.text.at(-1)) === (escaped === "b")) choices.push(atPosition(upper)); - result += "(?:" + choices.join("|") + ")"; + result += "(?:" + joinPrivateArray(choices, "|") + ")"; } else result += character + escaped; continue; } @@ -1307,13 +1336,19 @@ async function assertProviderRunsValid( introducedViolations.push(violation); continue; } - const trustedMatches = trustedSegments.flatMap((segment) => - patternOccurrences(segment.text, pattern) - .filter(createTrustedMatchPredicate(pattern, segment, text)) - .map((match) => ({ - index: segment.start + match.index, - text: match.text, - })) + const trustedMatches = flatMapPrivateArray( + trustedSegments, + (segment) => + mapPrivateArray( + filterPrivateArray( + patternOccurrences(segment.text, pattern), + createTrustedMatchPredicate(pattern, segment, text), + ), + (match) => ({ + index: segment.start + match.index, + text: match.text, + }), + ), ); const introduced = patternOccurrences(text, pattern).some((match) => !trustedMatches.some((trusted) => @@ -1458,8 +1493,9 @@ export function securityMiddleware( // would brick the conversation on every later turn (`extractMergedRunTexts`). registerTurnProviderRequestValidator(context, async (providerSystem, messages) => { const systemMessages = providerSystemMessages(providerSystem); - const pendingCurrent = typeof context.input === "string" ? [] : context.input - .filter((message) => message.role === "system"); + const pendingCurrent = typeof context.input === "string" + ? [] + : filterPrivateArray(context.input, (message) => message.role === "system"); // Separate occurrences even when a memory adapter returns the same // object twice. Current inputs are appended after historical messages. const callerMessages = mapPrivateArray( @@ -1480,7 +1516,10 @@ export function securityMiddleware( pendingCurrent.splice(current, 1); currentSystemMessages.add(callerMessages[index]!); } - const callerSystemMessages = callerMessages.filter((message) => message.role === "system"); + const callerSystemMessages = filterPrivateArray( + callerMessages, + (message) => message.role === "system", + ); const trusted = new Set(systemMessages); const callers = new Set(callerSystemMessages); const providerRuns: ProviderValidationRun[] = []; @@ -1500,7 +1539,7 @@ export function securityMiddleware( for (const [index, message] of run.entries()) { if (index > 0) assembled.text += runSeparator; const start = assembled.text.length; - const text = messageTextParts(message).join(partSeparator); + const text = joinPrivateArray(messageTextParts(message), partSeparator); assembled.text += text; const kind = trusted.has(message) ? "runtime" @@ -1527,14 +1566,19 @@ export function securityMiddleware( registerTurnMessageValidator(context, async (history, turnInput) => { const individualValues = history.length === 0 ? { - texts: turnInput - .filter((message) => !isSummaryMemoryProjectionMessage(message)) - .flatMap(extractMessageInputText), + texts: flatMapPrivateArray( + filterPrivateArray( + turnInput, + (message) => !isSummaryMemoryProjectionMessage(message), + ), + extractMessageInputText, + ), assembled: [ - ...turnInput - .filter(isSummaryMemoryProjectionMessage) - .flatMap(extractMessageInputText), - ...turnInput.flatMap(extractMessageAssembledTexts), + ...flatMapPrivateArray( + filterPrivateArray(turnInput, isSummaryMemoryProjectionMessage), + extractMessageInputText, + ), + ...flatMapPrivateArray(turnInput, extractMessageAssembledTexts), ], } : { texts: [], assembled: [] }; @@ -1563,7 +1607,7 @@ export function securityMiddleware( // Consume occurrences, rather than IDs, so duplicate IDs and freshly // deserialized messages retain their individual validation provenance. const remainingPrevious = previousMessages?.slice() ?? []; - const changed = messages.filter((message) => { + const changed = filterPrivateArray(messages, (message) => { const index = remainingPrevious.findIndex((previous) => previous.id === message.id && sameProviderMessageContent(previous, message) ); @@ -1571,12 +1615,16 @@ export function securityMiddleware( remainingPrevious.splice(index, 1); return false; }); - const texts = changed - .filter((message) => !isSummaryMemoryProjectionMessage(message)) - .flatMap(extractMessageInputText); + const texts = flatMapPrivateArray( + filterPrivateArray(changed, (message) => !isSummaryMemoryProjectionMessage(message)), + extractMessageInputText, + ); const assembled = [ - ...changed.filter(isSummaryMemoryProjectionMessage).flatMap(extractMessageInputText), - ...changed.flatMap(extractMessageAssembledTexts), + ...flatMapPrivateArray( + filterPrivateArray(changed, isSummaryMemoryProjectionMessage), + extractMessageInputText, + ), + ...flatMapPrivateArray(changed, extractMessageAssembledTexts), ]; const runTexts = extractMergedRunTexts( messages, @@ -1606,13 +1654,15 @@ export function securityMiddleware( if (typeof context.input !== "string") { assertTextsNeedNoSanitization( inputValidator, - context.input.filter((message) => message.role === "user") - .flatMap(( + flatMapPrivateArray( + filterPrivateArray(context.input, (message) => message.role === "user"), + ( message, ) => [ buildAttachmentContextFromParts(message.parts), ...getProviderAttachmentMetadata(message.parts), - ]), + ], + ), "Attachment annotations contain content sanitization removes", config.onViolation, ); @@ -1650,14 +1700,22 @@ export function securityMiddleware( approvedMessages.length === messages.length && approvedMessages.every((message, index) => message.id === messages[index]?.id); const roleRewriteCandidates = approvedMessages === undefined - ? messages.filter((message) => !VALIDATED_INPUT_ROLES.has(message.role)) - : messages.filter((message, index) => - !VALIDATED_INPUT_ROLES.has(message.role) && - (!sameMessageIdentity || VALIDATED_INPUT_ROLES.has(approvedMessages[index]!.role)) + ? filterPrivateArray(messages, (message) => !VALIDATED_INPUT_ROLES.has(message.role)) + : filterPrivateArray( + messages, + (message, index) => + !VALIDATED_INPUT_ROLES.has(message.role) && + (!sameMessageIdentity || VALIDATED_INPUT_ROLES.has(approvedMessages[index]!.role)), ); const rewrittenRoleTexts: InputValidationTexts = { - texts: roleRewriteCandidates.flatMap(extractMessageInputTextRegardlessOfRole), - assembled: roleRewriteCandidates.flatMap(extractMessageAssembledTextsRegardlessOfRole), + texts: flatMapPrivateArray( + roleRewriteCandidates, + extractMessageInputTextRegardlessOfRole, + ), + assembled: flatMapPrivateArray( + roleRewriteCandidates, + extractMessageAssembledTextsRegardlessOfRole, + ), }; const completeResolvedTexts: InputValidationTexts = { texts: [...resolvedTexts.texts, ...rewrittenRoleTexts.texts], diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index e972cecaef..8371accb56 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -1,4 +1,8 @@ -import { appendPrivateArray, pushPrivateArray } from "#veryfront/security/private-array.ts"; +import { + appendPrivateArray, + flatMapPrivateArray, + pushPrivateArray, +} from "#veryfront/security/private-array.ts"; /** * Text-Generation Runtime Message Converter * @@ -192,7 +196,7 @@ function getTextGenerationToolResultPart( /** @internal Provider-visible text annotation shared with input validation. */ export function buildAttachmentContextFromParts(parts: Message["parts"]): string { if (getTextFromParts(parts).includes("")) return ""; - const refs = parts.flatMap((part) => { + const refs = flatMapPrivateArray(parts, (part) => { const type = getStringPartField(part, "type"); if (type !== "file" && type !== "image") return []; @@ -252,7 +256,7 @@ function getUserFileParts( parts: Message["parts"], requireInternetReachableAttachments: boolean, ): TextGenerationRuntimeFilePart[] { - return parts.flatMap((part) => { + return flatMapPrivateArray(parts, (part) => { const type = getStringPartField(part, "type"); if (type !== "file" && type !== "image") return []; diff --git a/src/security/private-array.test.ts b/src/security/private-array.test.ts index aaedfb55db..844371b0ec 100644 --- a/src/security/private-array.test.ts +++ b/src/security/private-array.test.ts @@ -1,8 +1,43 @@ import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { appendPrivateArray, concatPrivateArrays, pushPrivateArray } from "./private-array.ts"; +import { + appendPrivateArray, + concatPrivateArrays, + filterPrivateArray, + flatMapPrivateArray, + joinPrivateArray, + pushPrivateArray, +} from "./private-array.ts"; describe("private array concatenation", () => { + it("filters and flattens own entries while preserving identity and callback indexes", () => { + const kept = { value: "kept" }; + const values = [kept, , { value: "removed" }]; + const indexes: number[] = []; + const filtered = filterPrivateArray(values, (value, index, source) => { + assertStrictEquals(source, values); + indexes.push(index); + return value === kept; + }); + assertEquals(indexes, [0, 2]); + assertStrictEquals(filtered[0], kept); + assertEquals(filtered.length, 1); + const flattened = flatMapPrivateArray( + values, + (value, index) => index === 0 ? [value, , value] : value, + ); + assertEquals(flattened.length, 3); + assertStrictEquals(flattened[0], kept); + assertStrictEquals(flattened[1], kept); + assertStrictEquals(flattened[2], values[2]); + }); + + it("joins text in order, including separators for sparse and empty entries", () => { + assertEquals(joinPrivateArray(["first", , "", "last"], "|"), "first|||last"); + assertEquals(joinPrivateArray(["first", "last"]), "first,last"); + assertEquals(joinPrivateArray([], "|"), ""); + }); + it("appends arrays without consulting an overridden iterator", () => { const value = { text: "Synthetic output" }; const source = [value]; diff --git a/src/security/private-array.ts b/src/security/private-array.ts index 274728b071..8ce28a0b2c 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -1,6 +1,60 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; const hasOwn = Object.hasOwn; +const isArray = Array.isArray; + +/** Filter private arrays through own elements without consulting array species. */ +export function filterPrivateArray( + values: readonly T[], + predicate: (value: T, index: number, values: readonly T[]) => value is S, +): S[]; +export function filterPrivateArray( + values: readonly T[], + predicate: (value: T, index: number, values: readonly T[]) => unknown, +): T[]; +export function filterPrivateArray( + values: readonly T[], + predicate: (value: T, index: number, values: readonly T[]) => unknown, +): T[] { + const output: T[] = []; + const length = values.length; + for (let index = 0; index < length; index++) { + if (!hasOwn(values, index)) continue; + const value = values[index]!; + if (predicate(value, index, values)) pushPrivateArray(output, value); + } + return output; +} + +/** Map and flatten one level of private array elements without observable methods. */ +export function flatMapPrivateArray( + values: readonly T[], + mapper: (value: T, index: number, values: readonly T[]) => U | readonly U[], +): U[] { + const output: U[] = []; + const length = values.length; + for (let index = 0; index < length; index++) { + if (!hasOwn(values, index)) continue; + const mapped = mapper(values[index]!, index, values); + if (isArray(mapped)) { + for (let inner = 0; inner < mapped.length; inner++) { + if (hasOwn(mapped, inner)) pushPrivateArray(output, mapped[inner]!); + } + } else pushPrivateArray(output, mapped as U); + } + return output; +} + +/** Join private strings without dispatching through a writable array method. */ +export function joinPrivateArray(values: readonly (string | undefined)[], separator = ","): string { + let output = ""; + const length = values.length; + for (let index = 0; index < length; index++) { + if (index > 0) output += separator; + if (hasOwn(values, index)) output += values[index] ?? ""; + } + return output; +} /** Append one private value without looking up push or invoking inherited setters. */ export function pushPrivateArray(values: T[], value: T): number { diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 987b41f11d..b4300b7a4a 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -16,6 +16,10 @@ describe("prepared executor private iteration", () => { "array iteration", "array append", "text extraction", + "array filtering", + "array mapping", + "array flattening", + "array joining", "promise chaining", ] ) { @@ -100,6 +104,9 @@ describe("prepared executor private iteration", () => { const originalArrayIterator = Array.prototype[Symbol.iterator]; const originalPush = Array.prototype.push; const originalFilter = Array.prototype.filter; + const originalMap = Array.prototype.map; + const originalFlatMap = Array.prototype.flatMap; + const originalJoin = Array.prototype.join; const originalThen = Promise.prototype.then; const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); const originalNext = prototype.next; @@ -124,6 +131,20 @@ describe("prepared executor private iteration", () => { } } }; + const observeTextArray = (values: unknown[]) => { + observeMessages(values); + for (let index = 0; index < values.length; index++) { + const value = values[index]; + if ( + value === marker || + (value !== null && typeof value === "object" && + Object.getOwnPropertyDescriptor(value, "text")?.value === marker) + ) { + observations++; + return; + } + } + }; const hook = (original: typeof originalNext) => async function (this: unknown, ...args: unknown[]) { const result = await Reflect.apply(original, this, args) as IteratorResult; @@ -170,6 +191,26 @@ describe("prepared executor private iteration", () => { : value; }, rejected]); }) as typeof originalThen; + } else if (probe === "array filtering") { + Array.prototype.filter = (function (this: unknown[], ...args: unknown[]) { + observeTextArray(this); + return Reflect.apply(originalFilter, this, args); + }) as typeof originalFilter; + } else if (probe === "array mapping") { + Array.prototype.map = (function (this: unknown[], ...args: unknown[]) { + observeTextArray(this); + return Reflect.apply(originalMap, this, args); + }) as typeof originalMap; + } else if (probe === "array joining") { + Array.prototype.join = function (separator) { + observeTextArray(this); + return Reflect.apply(originalJoin, this, [separator]); + }; + } else if (probe === "array flattening") { + Array.prototype.flatMap = (function (this: unknown[], ...args: unknown[]) { + observeTextArray(this); + return Reflect.apply(originalFlatMap, this, args); + }) as typeof originalFlatMap; } else { Object.defineProperty(Object.prototype, "metadata", { configurable: true, @@ -200,6 +241,9 @@ describe("prepared executor private iteration", () => { Array.prototype[Symbol.iterator] = originalArrayIterator; Array.prototype.push = originalPush; Array.prototype.filter = originalFilter; + Array.prototype.map = originalMap; + Array.prototype.flatMap = originalFlatMap; + Array.prototype.join = originalJoin; Promise.prototype.then = originalThen; if (originalMetadata) Object.defineProperty(Object.prototype, "metadata", originalMetadata); else Reflect.deleteProperty(Object.prototype, "metadata"); From fb1962caa74b176e3acf2778bd0d4af2222da3ec Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 8 Sep 2026 23:34:12 +0200 Subject: [PATCH 123/194] fix(agent): protect message preparation and replay filters --- src/agent/runtime/provider-replay.ts | 16 +++++---- src/chat/conversation.ts | 3 +- src/chat/message-prep.ts | 28 +++++++++------- src/chat/provider-message-tool-pair-repair.ts | 8 +++-- src/chat/provider-tool-history.ts | 3 +- src/security/private-array.test.ts | 33 +++++++++++++++++++ src/security/private-array.ts | 6 +++- 7 files changed, 73 insertions(+), 24 deletions(-) diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 557fe16747..8e205d5b4a 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1,3 +1,4 @@ +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; @@ -1272,11 +1273,12 @@ function assertCheckpointMatchesProjection( targetProviderToolResults: readonly Record[], ): void { const checkpointProjection = projectCheckpointVisibleParts(checkpoint); - const checkpointProviderToolResults = checkpointProjection.filter((part) => - part.type === "tool-result" + const checkpointProviderToolResults = filterPrivateArray( + checkpointProjection, + (part) => part.type === "tool-result", ); const checkpointVisibleProjection = normalizeTranscriptVisibleProjection( - checkpointProjection.filter((part) => part.type !== "tool-result"), + filterPrivateArray(checkpointProjection, (part) => part.type !== "tool-result"), ); const normalizedTargetProjection = normalizeTranscriptVisibleProjection(targetProjection); if ( @@ -1436,7 +1438,7 @@ function assertCheckpointMatchesSplitAssistantTurns( createCheckpointForRawBlocks(checkpoint, rawSegment.flat()), ); const rawSegmentAssistantProjection = normalizeTranscriptVisibleProjection( - rawSegmentProjection.filter((part) => part.type !== "tool-result"), + filterPrivateArray(rawSegmentProjection, (part) => part.type !== "tool-result"), ); const assistantProjection = normalizeTranscriptVisibleProjection( assistantMatches[index]!.parts.flatMap((part) => { @@ -1730,10 +1732,10 @@ export function applyProviderReplayCheckpointsToMessages( } assertAnthropicProviderToolResultsMatchTranscript(messages, checkpoints); for (const checkpoint of checkpoints) { - const matches = messages.filter((message) => message.id === checkpoint.messageId); + const matches = filterPrivateArray(messages, (message) => message.id === checkpoint.messageId); if (matches.length === 0) continue; - const assistantMatches = matches.filter((message) => message.role === "assistant"); - const toolSiblings = matches.filter((message) => message.role === "tool"); + const assistantMatches = filterPrivateArray(matches, (message) => message.role === "assistant"); + const toolSiblings = filterPrivateArray(matches, (message) => message.role === "tool"); const target = assistantMatches[0]; if (!target) { const role = matches[0]?.role; diff --git a/src/chat/conversation.ts b/src/chat/conversation.ts index c0995bab8b..9664d2a734 100644 --- a/src/chat/conversation.ts +++ b/src/chat/conversation.ts @@ -1,3 +1,4 @@ +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import type { ChatUiMessage, ChatUiMessagePart, ProviderModelMessage } from "./types.ts"; @@ -438,7 +439,7 @@ export function toConversationPartsFromUiMessage(message: ChatUiMessage): Messag } } - return parts.filter((part) => getMessagePartSchema().safeParse(part).success); + return filterPrivateArray(parts, (part) => getMessagePartSchema().safeParse(part).success); } function isToolComplete(part: ToolUiPart): boolean { diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 635c65e158..bb69e755f0 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1,3 +1,4 @@ +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; import { copyProviderModelMessageSourceId, getProviderModelMessageSourceId, @@ -829,7 +830,7 @@ export function stripPendingToolParts(messages: ChatUiMessage[]): ChatUiMessage[ return [message]; } - const parts = message.parts.filter((part) => { + const parts = filterPrivateArray(message.parts, (part) => { if ( isRecord(part) && (replayMatches.supersededToolCallParts.has(part) || @@ -883,7 +884,7 @@ function stripSupersededToolErrorParts(messages: ChatUiMessage[]): ChatUiMessage return [message]; } - const parts = message.parts.filter((part) => { + const parts = filterPrivateArray(message.parts, (part) => { if (!isToolErrorState(part)) { return true; } @@ -989,7 +990,10 @@ function hasValidContent(message: ProviderModelMessage): boolean { function cleanContent(content: T[], role: ProviderModelMessage["role"]): T[] { const hasSubstantiveContent = content.some((part) => isKeepableModelPart(part, role, false)); - return content.filter((part) => isKeepableModelPart(part, role, hasSubstantiveContent)); + return filterPrivateArray( + content, + (part) => isKeepableModelPart(part, role, hasSubstantiveContent), + ); } /** Sanitize provider model messages. */ @@ -1038,8 +1042,9 @@ function filterValidMessages( messages: ProviderModelMessage[], options: ProviderMessageSanitizationOptions = {}, ): ProviderModelMessage[] { - return messages.filter((message) => - hasValidContent(message) || shouldPreserveEmptyAssistantMessage(message, options) + return filterPrivateArray( + messages, + (message) => hasValidContent(message) || shouldPreserveEmptyAssistantMessage(message, options), ); } @@ -1068,8 +1073,9 @@ export function prepareProviderModelMessagesFromUiMessages( messages: ChatUiMessage[], options: PrepareProviderModelMessagesFromUiMessagesOptions = {}, ): ProviderModelMessage[] { - const validMessages = messages.filter((message) => - message && typeof message === "object" && "role" in message + const validMessages = filterPrivateArray( + messages, + (message) => message && typeof message === "object" && "role" in message, ); const normalizedMessages = normalizeMessageFilePartMediaTypes(validMessages); const strippedProviderOwnedToolMessages = stripProviderOwnedToolParts( @@ -1231,7 +1237,7 @@ function compactHistoricalField( if (field.kind === "string-array") { if (!Array.isArray(fieldValue)) return null; - const strings = fieldValue.filter((item) => typeof item === "string"); + const strings = filterPrivateArray(fieldValue, (item) => typeof item === "string"); return strings.length > 0 ? strings : null; } @@ -1263,7 +1269,7 @@ function compactHistoricalField( } if (Array.isArray(fieldValue)) { - const strings = fieldValue.filter((item) => typeof item === "string"); + const strings = filterPrivateArray(fieldValue, (item) => typeof item === "string"); return strings.length > 0 ? strings : null; } @@ -1406,7 +1412,7 @@ export function maskOldToolOutputs( if (sourceMessageId && preservedSourceMessageIds.has(sourceMessageId)) { return msg; } - const filtered = msg.content.filter((part) => !isReasoningPart(part)); + const filtered = filterPrivateArray(msg.content, (part) => !isReasoningPart(part)); if (filtered.length !== msg.content.length) { return copyProviderModelMessageSourceId(msg, { ...msg, content: filtered }); } @@ -1747,7 +1753,7 @@ export function dedupeToolHistory(messages: ProviderModelMessage[]): ProviderMod const deduped: ProviderModelMessage[] = []; const filterParts = (parts: T[]): { filtered: T[]; changed: boolean } => { - const filtered = parts.filter((part) => { + const filtered = filterPrivateArray(parts, (part) => { if (isToolCallPart(part)) { if (seenToolCallIds.has(part.toolCallId)) { mutated = true; diff --git a/src/chat/provider-message-tool-pair-repair.ts b/src/chat/provider-message-tool-pair-repair.ts index 226fc88fca..849a199ad1 100644 --- a/src/chat/provider-message-tool-pair-repair.ts +++ b/src/chat/provider-message-tool-pair-repair.ts @@ -1,3 +1,4 @@ +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; import { copyProviderModelMessageSourceId, getProviderModelMessageSourceId, @@ -91,8 +92,9 @@ function repairToolPairsWithOptions( continue; } - const unresolvedCalls = regularToolCalls.filter((toolCall) => - !hasImmediateToolResult(nextMessage, toolCall.id) + const unresolvedCalls = filterPrivateArray( + regularToolCalls, + (toolCall) => !hasImmediateToolResult(nextMessage, toolCall.id), ); if (unresolvedCalls.length === 0) { continue; @@ -115,7 +117,7 @@ function repairToolPairsWithOptions( } let removedFromLater = false; - const keptLaterContent = laterMessage.content.filter((part) => { + const keptLaterContent = filterPrivateArray(laterMessage.content, (part) => { if (!isToolResultPart(part)) { return true; } diff --git a/src/chat/provider-tool-history.ts b/src/chat/provider-tool-history.ts index 872f7800e7..c899eee5f3 100644 --- a/src/chat/provider-tool-history.ts +++ b/src/chat/provider-tool-history.ts @@ -1,3 +1,4 @@ +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; import { getStringField } from "./conversation.ts"; import type { ChatUiMessage } from "./types.ts"; @@ -48,7 +49,7 @@ export function stripProviderOwnedToolParts( } let mutated = false; - const parts = message.parts.filter((part) => { + const parts = filterPrivateArray(message.parts, (part) => { const toolName = getMessagePartToolName(part); const toolCallId = getMessagePartToolCallId(part); const ownedByName = toolName ? providerOwnedNames.has(toolName) : false; diff --git a/src/security/private-array.test.ts b/src/security/private-array.test.ts index 844371b0ec..8cccb2f9b0 100644 --- a/src/security/private-array.test.ts +++ b/src/security/private-array.test.ts @@ -10,6 +10,39 @@ import { } from "./private-array.ts"; describe("private array concatenation", () => { + it("filters own values while preserving callback context and element identity", () => { + const first = { value: 1 }; + const second = { value: 2 }; + const source = [first, second]; + source.length = 3; + let observations = 0; + Object.setPrototypeOf( + source, + Object.create(Array.prototype, { + filter: { + get() { + observations++; + return Array.prototype.filter; + }, + }, + 2: { + get() { + observations++; + return { value: 3 }; + }, + }, + }), + ); + const context = { minimum: 2 }; + const result = filterPrivateArray(source, function (this: typeof context, value) { + assertStrictEquals(this, context); + return value.value >= this.minimum; + }, context); + assertEquals(result, [second]); + assertStrictEquals(result[0], second); + assertEquals(observations, 0); + }); + it("filters and flattens own entries while preserving identity and callback indexes", () => { const kept = { value: "kept" }; const values = [kept, , { value: "removed" }]; diff --git a/src/security/private-array.ts b/src/security/private-array.ts index 8ce28a0b2c..e1ba8d2e7d 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -2,26 +2,30 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts" const hasOwn = Object.hasOwn; const isArray = Array.isArray; +const apply = Reflect.apply; /** Filter private arrays through own elements without consulting array species. */ export function filterPrivateArray( values: readonly T[], predicate: (value: T, index: number, values: readonly T[]) => value is S, + thisArg?: unknown, ): S[]; export function filterPrivateArray( values: readonly T[], predicate: (value: T, index: number, values: readonly T[]) => unknown, + thisArg?: unknown, ): T[]; export function filterPrivateArray( values: readonly T[], predicate: (value: T, index: number, values: readonly T[]) => unknown, + thisArg?: unknown, ): T[] { const output: T[] = []; const length = values.length; for (let index = 0; index < length; index++) { if (!hasOwn(values, index)) continue; const value = values[index]!; - if (predicate(value, index, values)) pushPrivateArray(output, value); + if (apply(predicate, thisArg, [value, index, values])) pushPrivateArray(output, value); } return output; } From 1b588eff6f89efe68b7478939b52c57de72a5120 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 08:57:53 +0200 Subject: [PATCH 124/194] fix(agent): preserve tool grants through managed steering refresh --- docs/api-reference/veryfront/agent.md | 23 +++++----- docs/guides/agent-service-runtime.md | 7 ++++ .../hosted/executor-runtime-prepare.test.ts | 5 ++- src/agent/hosted/executor-runtime-prepare.ts | 7 +++- .../hosted/executor-state-bridge.test.ts | 30 +++++++++++++ src/agent/hosted/executor-state-bridge.ts | 32 +++++++++++--- src/agent/hosted/executor-state-schema.ts | 5 +++ .../hosted/managed-broker-project-state.ts | 12 +++++- src/agent/hosted/managed-executor-broker.ts | 3 +- src/agent/service/managed-broker.ts | 1 + .../managed-broker-project-state.test.ts | 42 ++++++++++++++++++- 11 files changed, 143 insertions(+), 24 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 06b4cbaffc..f634a1bdfe 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -1995,17 +1995,18 @@ import { #### Functions -| Name | Description | Source | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `connectExecutorTransport` | Node-only TLS 1.3 PSK. No certificate fallback, session reuse, or reconnect. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | -| `createHostedExecutorAllocatorClient` | Trusted broker client for the operator's dedicated TLS endpoint. Each call reads the rotated Pod-bound token. No redirects, automatic POST retries, arbitrary headers, application credentials, or ambient gateway fallback. The returned promise retains raw token-read and socket ownership; the session supplies prompt cancellation notification separately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-allocator-client.ts) | -| `createManagedBrokerHandler` | Handle signed run invocations with configured detached or request-owned SSE responses. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | -| `createManagedBrokerPersistence` | Create exact-run API persistence callbacks while retaining credentials in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | -| `createManagedExecutorBroker` | Compose an executor pool with authenticated installation and operation gates. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `parseBrokerRuntimeAgentIngress` | Read and verify a signed invocation once before constructing executor-safe data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `parseManagedAgUiAgentIngress` | Parse the trusted broker's direct request-owned AG-UI ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `parseManagedDurableAgentIngress` | Parse the trusted broker's direct canonical durable-run ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `startNodeManagedAgentBroker` | Bind managed routes and stop admission before joining all work during shutdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | +| Name | Description | Source | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `connectExecutorTransport` | Node-only TLS 1.3 PSK. No certificate fallback, session reuse, or reconnect. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | +| `createHostedExecutorAllocatorClient` | Trusted broker client for the operator's dedicated TLS endpoint. Each call reads the rotated Pod-bound token. No redirects, automatic POST retries, arbitrary headers, application credentials, or ambient gateway fallback. The returned promise retains raw token-read and socket ownership; the session supplies prompt cancellation notification separately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-allocator-client.ts) | +| `createManagedBrokerHandler` | Handle signed run invocations with configured detached or request-owned SSE responses. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `createManagedBrokerPersistence` | Create exact-run API persistence callbacks while retaining credentials in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | +| `createManagedBrokerProjectState` | Create broker-owned project steering. Refreshes show skills only when the broker-validated effective tool selection includes `load_skill`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-project-state.ts) | +| `createManagedExecutorBroker` | Compose an executor pool with authenticated installation and operation gates. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `parseBrokerRuntimeAgentIngress` | Read and verify a signed invocation once before constructing executor-safe data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `parseManagedAgUiAgentIngress` | Parse the trusted broker's direct request-owned AG-UI ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `parseManagedDurableAgentIngress` | Parse the trusted broker's direct canonical durable-run ingress. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `startNodeManagedAgentBroker` | Bind managed routes and stop admission before joining all work during shutdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | #### Classes diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 49c7fe127d..740052e574 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -299,6 +299,13 @@ Services that use Veryfront Cloud project steering can reuse `fetchDefaultAgentServiceProjectSteering()` for the initial fetch and `createDefaultAgentServiceProjectSteeringRefresh()` for step-boundary refresh. +Managed brokers can use `createManagedBrokerProjectState()` from +`veryfront/agent/managed-broker` for project instructions and skill catalogs. +The executor sends its effective tool selection on each steering refresh. The +broker validates that selection against the installed grant before constructing +instructions, and includes skills only when `load_skill` remains available. +Refreshes without a tool selection omit the skill catalog. + ## Keep inference authority separate Signed runtime invocations may include an optional diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 3cbd7d8998..b1ed59e3f2 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -1544,6 +1544,7 @@ Synthetic source instructions.`, it(`refreshes steering after ${path} only on a successful steering change (${success})`, async () => { let calls = 0; let refreshes = 0; + let refreshedTools: readonly string[] | undefined; const systems: string[] = []; const f = fixture({ grant: { @@ -1555,8 +1556,9 @@ Synthetic source instructions.`, facades: { projectSteering: { prepare: ({ definition }) => Promise.resolve({ agent: definition }), - refresh: () => { + refresh: (_signal, availableToolNames) => { refreshes++; + refreshedTools = availableToolNames; return "Updated synthetic steering"; }, }, @@ -1586,6 +1588,7 @@ Synthetic source instructions.`, await Array.fromAsync(await preparedStream(f)); assertEquals(calls, 2); assertEquals(refreshes, expectedRefreshes); + assertEquals(refreshedTools, expectedRefreshes === 1 ? ["update_file"] : undefined); assertEquals(systems[1]?.includes("Updated synthetic steering"), expectedRefreshes === 1); } finally { await f.owner.close(); diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index bc49b14b6e..75402c8396 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -153,7 +153,10 @@ export interface ExecutorRuntimeFacades { signal: AbortSignal; }, ): Promise>; - refresh(signal: AbortSignal): Promise | AgentSystem; + refresh( + signal: AbortSignal, + availableToolNames?: readonly string[], + ): Promise | AgentSystem; }; latestConversationUserText?: (signal: AbortSignal) => Promise; publishParentRunEvents?: NonNullable; @@ -621,7 +624,7 @@ export function createExecutorRuntimePreparation(input: Options) { modelId, sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, refreshSystem: facades.projectSteering - ? () => facades.projectSteering!.refresh(streamSignal) + ? () => facades.projectSteering!.refresh(streamSignal, allowedToolNames) : undefined, }, { resolveModelRuntime, diff --git a/src/agent/hosted/executor-state-bridge.test.ts b/src/agent/hosted/executor-state-bridge.test.ts index 152e630749..3334c697c1 100644 --- a/src/agent/hosted/executor-state-bridge.test.ts +++ b/src/agent/hosted/executor-state-bridge.test.ts @@ -43,6 +43,36 @@ function pair(operations: ReadonlyMap) { } describe("executor state bridge", () => { + it("forwards a narrowed tool selection and rejects names outside the installed grant", async () => { + const seen: unknown[] = []; + const channels = pair(createExecutorStateBroker({ + expectedBinding: binding, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + allowedToolNames: ["read_file", "load_skill"], + prepareProjectSteering: async (input) => ({ agent: input.definition }), + refreshProjectSteering: async (_signal, availableToolNames) => { + seen.push(availableToolNames); + return "refresh"; + }, + })); + try { + const facades = createExecutorStateFacades({ + channel: channels.executor, + ...scope, + capabilityIds: { projectSteering: capabilityIds.projectSteering }, + }); + const signal = new AbortController().signal; + await facades.projectSteering!.refresh(signal, ["read_file"]); + await facades.projectSteering!.refresh(signal, ["load_skill"]); + await facades.projectSteering!.refresh(signal); + await assertRejects(() => facades.projectSteering!.refresh(signal, ["write_file"])); + assertEquals(seen, [["read_file"], ["load_skill"], []]); + } finally { + await channels.close(); + } + }); + it("uses broker-owned scope and returns bounded steering and conversation state", async () => { const seen: unknown[] = []; const channels = pair(createExecutorStateBroker({ diff --git a/src/agent/hosted/executor-state-bridge.ts b/src/agent/hosted/executor-state-bridge.ts index 8b3f8852af..2793440b01 100644 --- a/src/agent/hosted/executor-state-bridge.ts +++ b/src/agent/hosted/executor-state-bridge.ts @@ -15,6 +15,7 @@ import { getExecutorAgentSystemSchema, getExecutorConversationUserTextResultSchema, getExecutorProjectSteeringPrepareRequestSchema, + getExecutorProjectSteeringRefreshRequestSchema, getExecutorProjectSteeringResultSchema, getExecutorStateCapabilityIdsSchema, getExecutorStateReadRequestSchema, @@ -36,7 +37,7 @@ export interface ExecutorStateFacades { prepare(input: ProjectSteeringPrepareInput): Promise< HostedChatRuntimeProjectSteering >; - refresh(signal: AbortSignal): Promise; + refresh(signal: AbortSignal, availableToolNames?: readonly string[]): Promise; }; latestConversationUserText?: (signal: AbortSignal) => Promise; } @@ -73,15 +74,21 @@ export function createExecutorStateBroker( options: Scope & { expectedBinding: ExecutorBinding; capabilityIds: ExecutorStateCapabilityIds; + /** Installed tool grant used to authorize the executor's narrowed refresh selection. */ + allowedToolNames?: readonly string[]; prepareProjectSteering?: ( input: ProjectSteeringPrepareInput, ) => Promise>; - refreshProjectSteering?: (signal: AbortSignal) => Promise | AgentSystem; + refreshProjectSteering?: ( + signal: AbortSignal, + availableToolNames?: readonly string[], + ) => Promise | AgentSystem; latestConversationUserText?: NonNullable; }, ): ReadonlyMap { const expectedBinding = Object.freeze(getExecutorBindingSchema().parse(options.expectedBinding)); const scope = parseScope(options); + const allowedToolNames = new Set(options.allowedToolNames ?? []); const capabilityIds = Object.freeze( parseExecutorStateData(getExecutorStateCapabilityIdsSchema(), options.capabilityIds), ); @@ -143,9 +150,19 @@ export function createExecutorStateBroker( operations.set(executorStateOperations.refreshProjectSteering, { mode: "unary", async handle(value, context) { - const request = parseExecutorStateData(getExecutorStateReadRequestSchema(), value); + const request = parseExecutorStateData( + getExecutorProjectSteeringRefreshRequestSchema(), + value, + ); authorize(context, expectedBinding, request.capabilityId, capabilityId); - const result = await scheduleSteering(context, () => refresh(context.signal)); + const availableToolNames = request.availableToolNames ?? []; + if (availableToolNames.some((name) => !allowedToolNames.has(name))) { + throw new TypeError("Managed state tool selection is not authorized"); + } + const result = await scheduleSteering( + context, + () => refresh(context.signal, availableToolNames), + ); return executorStateJson( parseExecutorStateData(getExecutorAgentSystemSchema(), executorStateJson(result)), ); @@ -213,11 +230,14 @@ export function createExecutorStateFacades( } return parsed; }, - async refresh(signal: AbortSignal) { + async refresh(signal: AbortSignal, availableToolNames?: readonly string[]) { const combined = options.signal ? AbortSignal.any([options.signal, signal]) : signal; const result = await options.channel.request( executorStateOperations.refreshProjectSteering, - { capabilityId: capabilityIds.projectSteering! }, + { + capabilityId: capabilityIds.projectSteering!, + availableToolNames: [...availableToolNames ?? []], + }, { signal: combined }, ); return parseExecutorStateData(getExecutorAgentSystemSchema(), result); diff --git a/src/agent/hosted/executor-state-schema.ts b/src/agent/hosted/executor-state-schema.ts index d4d0c1d681..62ed5b7716 100644 --- a/src/agent/hosted/executor-state-schema.ts +++ b/src/agent/hosted/executor-state-schema.ts @@ -57,6 +57,11 @@ export const getExecutorProjectSteeringPrepareRequestSchema = defineSchema((_v) getCapabilityRequestSchema().extend({ definition: getExecutorAgentDefinitionSchema() }).strict() ); export const getExecutorStateReadRequestSchema = getCapabilityRequestSchema; +export const getExecutorProjectSteeringRefreshRequestSchema = defineSchema((v) => + getCapabilityRequestSchema().extend({ + availableToolNames: v.array(getExecutorDiscoveryIdSchema()).max(1_000).optional(), + }).strict() +); const getSkillSelectorPolicySchema = defineSchema((v) => v.discriminatedUnion("kind", [ diff --git a/src/agent/hosted/managed-broker-project-state.ts b/src/agent/hosted/managed-broker-project-state.ts index 746199344f..a3d13f29f6 100644 --- a/src/agent/hosted/managed-broker-project-state.ts +++ b/src/agent/hosted/managed-broker-project-state.ts @@ -23,6 +23,10 @@ import type { HostedChatRuntimeProjectSteering } from "./chat-runtime-contract.t type Scope = { projectId: string | null; branchId?: string | null }; +/** + * Create broker-owned project steering. Refreshes show skills only when the + * broker-validated effective tool selection includes `load_skill`. + */ export function createManagedBrokerProjectState( options: Scope & { apiUrl: string | URL; @@ -117,7 +121,10 @@ export function createManagedBrokerProjectState( ...(selected.definitions.length ? { initialSkills: selected.definitions } : {}), }; }, - async refreshProjectSteering(signal: AbortSignal): Promise { + async refreshProjectSteering( + signal: AbortSignal, + availableToolNames: readonly string[] = [], + ): Promise { if (!definition) throw new TypeError("Managed broker project state is not prepared"); const loaded = await load(signal); const selected = select(definition, loaded.skills); @@ -126,7 +133,8 @@ export function createManagedBrokerProjectState( projectId, branchId, instructions: loaded.instructions, - skills: selected.definitions, + skills: availableToolNames.includes("load_skill") ? selected.definitions : [], + availableToolNames, environmentContext: options.environmentContext, }); }, diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index 0182479c74..cfc6903a6b 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -58,7 +58,7 @@ type PersistenceInput = Omit< >; type StateInput = Omit< Parameters[0], - "expectedBinding" | "capabilityIds" | "agentId" | "projectId" | "branchId" + "expectedBinding" | "capabilityIds" | "agentId" | "projectId" | "branchId" | "allowedToolNames" >; /** Prepared executor handle with broker-owned execution and retirement. */ @@ -325,6 +325,7 @@ function buildBrokerOperations( projectId: execution.projectId, branchId: execution.branchId, ...input.state, + allowedToolNames: installation.grant.allowedToolNames, }); const combined = new Map(); for (const operations of [model, tools, persistence, state]) { diff --git a/src/agent/service/managed-broker.ts b/src/agent/service/managed-broker.ts index 55ad0abba8..3cabce3765 100644 --- a/src/agent/service/managed-broker.ts +++ b/src/agent/service/managed-broker.ts @@ -9,6 +9,7 @@ export { createManagedBrokerPersistence, type ManagedBrokerOutput, } from "../hosted/managed-broker-persistence.ts"; +export { createManagedBrokerProjectState } from "../hosted/managed-broker-project-state.ts"; export { createHostedExecutorAllocatorClient } from "../hosted/executor-allocator-client.ts"; export { connectExecutorTransport, diff --git a/tests/integration/agent/managed-broker-project-state.test.ts b/tests/integration/agent/managed-broker-project-state.test.ts index b1b7c67b6c..c658f17438 100644 --- a/tests/integration/agent/managed-broker-project-state.test.ts +++ b/tests/integration/agent/managed-broker-project-state.test.ts @@ -1,7 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { createManagedBrokerProjectState } from "#veryfront/agent/hosted/managed-broker-project-state.ts"; +import { createManagedBrokerProjectState } from "veryfront/agent/managed-broker"; const definition = { id: "coder", @@ -12,6 +12,46 @@ const definition = { }; describe("managed broker project state", () => { + for (const availableToolNames of [undefined, [], ["read_file"], ["load_skill"]]) { + it(`keeps refreshed skills gated by the effective tool selection: ${availableToolNames}`, async () => { + const marker = "Synthetic selected skill catalog marker"; + const state = createManagedBrokerProjectState({ + apiUrl: "https://api.example.test", + authToken: "broker-token", + agentId: "coder", + projectId: "project-1", + builtinSkills: [{ + id: "guide", + name: "Guide", + description: marker, + instructions: "Help.", + allowedTools: [], + }], + fetch: (value) => + Promise.resolve( + new URL(value).pathname.endsWith("/AGENTS.md") + ? Response.json({ path: "AGENTS.md", content: "Current project instructions" }) + : Response.json({ data: [], page_info: { next: null } }), + ), + }); + const prepared = await state.prepareProjectSteering({ + definition: { ...definition, skills: true }, + projectId: "project-1", + signal: new AbortController().signal, + }); + assertEquals(prepared.initialSkills?.length, 1); + const refreshed = await state.refreshProjectSteering( + new AbortController().signal, + availableToolNames, + ); + assertEquals(JSON.stringify(refreshed).includes("Current project instructions"), true); + assertEquals( + JSON.stringify(refreshed).includes(marker), + availableToolNames?.includes("load_skill") ?? false, + ); + }); + } + it("joins the original catalog lookup before propagating an instruction failure", async () => { const failure = new Error("synthetic instruction failure"); const catalog = Promise.withResolvers(); From 6996f5631519300dcb4cd642c8739cd775a3e8d6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 09:45:02 +0200 Subject: [PATCH 125/194] fix(agent): capture validator and stream state operations --- .../middleware/security/validator.test.ts | 54 +++++++++++ src/agent/middleware/security/validator.ts | 25 +++-- src/agent/runtime/chat-stream-handler.test.ts | 29 ++++++ src/agent/runtime/chat-stream-handler.ts | 7 +- src/agent/runtime/index.ts | 9 +- src/chat/conversation.ts | 6 +- src/chat/message-prep.ts | 92 +----------------- src/chat/provider-message-content.ts | 94 +++++++++++++++++++ src/security/private-map.test.ts | 53 +++++++++++ src/security/private-map.ts | 57 +++++++++++ src/security/private-set.test.ts | 28 ++++++ src/security/private-set.ts | 5 +- 12 files changed, 349 insertions(+), 110 deletions(-) create mode 100644 src/chat/provider-message-content.ts create mode 100644 src/security/private-map.test.ts create mode 100644 src/security/private-map.ts diff --git a/src/agent/middleware/security/validator.test.ts b/src/agent/middleware/security/validator.test.ts index 4d8a9bd0e9..f78ad7b19f 100644 --- a/src/agent/middleware/security/validator.test.ts +++ b/src/agent/middleware/security/validator.test.ts @@ -51,6 +51,29 @@ function createResponse(text: string): AgentResponse { } describe("InputValidator", () => { + it("matches blocked input without consulting pattern test or exec overrides", async () => { + let observations = 0; + const pattern = /blocked/i; + Object.defineProperties(pattern, { + test: { + value() { + observations++; + return false; + }, + }, + exec: { + value() { + observations++; + return null; + }, + }, + }); + const validator = new InputValidator({ blockedPatterns: [pattern] }); + assertEquals((await validator.validate("synthetic blocked input")).valid, false); + assertEquals((await validator.validate("synthetic ordinary input")).valid, true); + assertEquals(observations, 0); + }); + it("collects max length, blocked pattern, and custom validation violations", async () => { const validator = new InputValidator({ maxLength: 5, @@ -205,6 +228,37 @@ describe("OutputFilter", () => { }); describe("securityMiddleware", () => { + it("validates second-turn membership without consulting the input iterator", async () => { + const context = createContext(); + await securityMiddleware({ input: { blockedPatterns: [/blocked phrase/] } })( + context, + () => Promise.resolve(createResponse("ok")), + ); + const validateTurn = getTurnMessageValidator(context)!; + const current: Message[] = [{ + id: "current", + role: "user", + parts: [{ type: "text", text: "phrase" }], + }]; + let observations = 0; + Object.defineProperty(current, Symbol.iterator, { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }); + await assertRejects( + () => + validateTurn( + [{ id: "previous", role: "user", parts: [{ type: "text", text: "blocked " }] }], + current, + ), + Error, + "Input validation failed", + ); + assertEquals(observations, 0); + }); + it("reports structured user input violations and throws a veryfront error", async () => { const violations: string[] = []; const middleware = securityMiddleware({ diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 1b12703b7e..c57209f601 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,3 +1,4 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { concatPrivateArrays, filterPrivateArray, @@ -129,10 +130,18 @@ const PII_REPLACEMENTS: Array<{ pattern: RegExp; label: string }> = [ { pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, label: "[CREDIT_CARD]" }, ]; +const RegExpConstructor = RegExp; +const regexpExec = RegExp.prototype.exec; +const applyRegExp = Reflect.apply; + +function testBlockedPattern(pattern: RegExp, input: string): boolean { + return applyRegExp(regexpExec, freshStatefulPattern(pattern), [input]) !== null; +} + function freshStatefulPattern(pattern: RegExp): RegExp { if (!pattern.global && !pattern.sticky) return pattern; - const matcher = new RegExp(pattern.source, pattern.flags); + const matcher = new RegExpConstructor(pattern.source, pattern.flags); if (pattern.sticky) matcher.lastIndex = pattern.lastIndex; return matcher; } @@ -224,7 +233,7 @@ export class InputValidator { // Blocked pattern groups are shared module-level objects reused across // requests. Test stateful patterns through a fresh matcher so lastIndex // cannot skip a repeat match and caller-owned patterns remain untouched. - if (!freshStatefulPattern(pattern).test(input)) continue; + if (!testBlockedPattern(pattern, input)) continue; violations.push({ type: "input", @@ -309,7 +318,7 @@ export class OutputFilter { for (const pattern of this.config.blockedPatterns ?? []) { // See InputValidator.validate: shared /g patterns must not carry // lastIndex across calls or require caller-owned regexes to be mutable. - if (!freshStatefulPattern(pattern).test(filtered)) continue; + if (!testBlockedPattern(pattern, filtered)) continue; violations.push({ type: "output", @@ -722,7 +731,7 @@ function extractMergedRunTexts( const sameOccurrence = previousMessages ? createMessageOccurrenceMatcher(previousMessages, messages) : undefined; - const runTexts = new Set(); + const runTexts = createPrivateSet(); for (const run of extractMergedRuns(messages)) { // Only an identical grouping keeps its provenance. Comparing text alone // would also exempt a newly joined boundary that happens to duplicate a @@ -1505,7 +1514,7 @@ export function securityMiddleware( ? { id: message.id, role: message.role, parts: message.parts } : message, ); - const currentSystemMessages = new Set(); + const currentSystemMessages = createPrivateSet(); for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]!; if (message.role !== "system") continue; @@ -1520,8 +1529,8 @@ export function securityMiddleware( callerMessages, (message) => message.role === "system", ); - const trusted = new Set(systemMessages); - const callers = new Set(callerSystemMessages); + const trusted = createPrivateSet(systemMessages); + const callers = createPrivateSet(callerSystemMessages); const providerRuns: ProviderValidationRun[] = []; for ( const run of extractMergedSystemRuns(concatPrivateArrays(systemMessages, callerMessages)) @@ -1584,7 +1593,7 @@ export function securityMiddleware( : { texts: [], assembled: [] }; const runTexts = extractMergedRunTexts( concatPrivateArrays(history, turnInput), - new Set(turnInput), + createPrivateSet(turnInput), ); // Merged runs are synthetic assemblies, so they are pattern-checked but // never length-checked (`InputValidationOptions.checkMaxLength`). diff --git a/src/agent/runtime/chat-stream-handler.test.ts b/src/agent/runtime/chat-stream-handler.test.ts index cba41d9d07..891ca4b594 100644 --- a/src/agent/runtime/chat-stream-handler.test.ts +++ b/src/agent/runtime/chat-stream-handler.test.ts @@ -98,6 +98,34 @@ describe("chat-stream-handler", () => { }); describe("createStreamState", () => { + it("keeps tool-call state methods independent of its mutable prototype", () => { + const state = createStreamState(); + let observations = 0; + if (Object.isExtensible(state.toolCalls)) { + Object.setPrototypeOf( + state.toolCalls, + Object.create(Map.prototype, { + set: { + value(key: string, value: unknown) { + observations++; + return Map.prototype.set.call(this, key, value); + }, + }, + get: { + value(key: string) { + observations++; + return Map.prototype.get.call(this, key); + }, + }, + }), + ); + } + const call = { id: "call", name: "read_file", arguments: '{"path":"example.txt"}' }; + state.toolCalls.set(call.id, call); + assertEquals(state.toolCalls.get(call.id), call); + assertEquals(observations, 0); + }); + it("returns a clean initial state", () => { const state = createStreamState(); assertEquals(state.accumulatedText, ""); @@ -386,6 +414,7 @@ describe("chat-stream-handler", () => { await processStream(result, state, controller, encoder, "text-1", undefined); assertEquals(appendLookups, 0); + assertEquals(Object.getPrototypeOf(state.reasoningParts[0]), null); assertEquals(state.reasoningParts, [{ id: "thinking-0", text: "Check evidence.", diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 6176c6fde9..76808732a3 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,3 +1,4 @@ +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { pushPrivateArray } from "#veryfront/security/private-array.ts"; import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { @@ -548,7 +549,7 @@ export function createStreamState(): ChatStreamState { accumulatedText: "", reasoningParts: [], finishReason: null, - toolCalls: new Map(), + toolCalls: createPrivateMap(), toolResults: [], suppressedToolCalls: [], usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, @@ -831,7 +832,7 @@ export function processStreamInternal( let activeTextPartId: string | undefined; let nextTextSegmentIndex = 0; let activeReasoningId: string | null = null; - const reasoningParts = new Map(); + const reasoningParts = createPrivateMap(); let shouldStopForCommittedLocalToolCall = false; let hasActiveLocalToolInput = false; const providerExecutedToolNames = new Set(callbacks?.providerExecutedToolNames ?? []); @@ -974,7 +975,7 @@ export function processStreamInternal( activeReasoningId = reasoningId; if (!reasoningParts.has(reasoningId)) { - const part = { id: reasoningId, text: "" }; + const part = { __proto__: null, id: reasoningId, text: "" }; reasoningParts.set(reasoningId, part); pushPrivateArray(state.reasoningParts, part); } diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index d51e7421a8..ee3d120eec 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1,6 +1,7 @@ import { appendPrivateArray, concatPrivateArrays, + filterPrivateArray, mapPrivateArray, pushPrivateArray, } from "#veryfront/security/private-array.ts"; @@ -3787,7 +3788,11 @@ export class AgentRuntime { // This is a stopgap: reasoning is default-on across the hosted catalog, // which makes recovery inert on most hosted paths. See #3736 for the // reconciliation protocol that would let it run again. - const hasExposedReasoning = state.reasoningParts.some(isPersistedReasoningPart); + const persistedReasoningParts = filterPrivateArray( + state.reasoningParts, + isPersistedReasoningPart, + ); + const hasExposedReasoning = persistedReasoningParts.length > 0; const canRecoverInterruptedLocalToolBatch = !recoveredInterruptedLocalToolBatch && step + 1 < maxSteps && !hasExposedReasoning; @@ -3818,7 +3823,7 @@ export class AgentRuntime { logger.warn("Declined interrupted local tool batch recovery after exposed reasoning", { step, toolName: streamedToolCalls.find(isInterruptedClientToolCall)?.name, - reasoningPartCount: state.reasoningParts.filter(isPersistedReasoningPart).length, + reasoningPartCount: persistedReasoningParts.length, }); } const assistantMessage = buildStreamedAssistantMessage({ diff --git a/src/chat/conversation.ts b/src/chat/conversation.ts index 9664d2a734..d6bc7f1e0a 100644 --- a/src/chat/conversation.ts +++ b/src/chat/conversation.ts @@ -269,11 +269,7 @@ function isProviderOwnedInputAvailableTool(input: { state: string; providerExecuted?: unknown; }): boolean { - if (input.state !== "input-available") { - return false; - } - - return input.providerExecuted === true; + return input.state === "input-available" && input.providerExecuted === true; } /** Push tool parts. */ diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index bb69e755f0..954b395df2 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1,3 +1,4 @@ +import { cleanContent, hasValidContent } from "./provider-message-content.ts"; import { filterPrivateArray } from "#veryfront/security/private-array.ts"; import { copyProviderModelMessageSourceId, @@ -905,97 +906,6 @@ function stripSupersededToolErrorParts(messages: ChatUiMessage[]): ChatUiMessage }); } -function hasNonEmptyStringField(record: Record, key: string): boolean { - return typeof record[key] === "string" && record[key].trim().length > 0; -} - -function hasValidToolResultOutput(value: unknown): boolean { - if (!isRecord(value) || typeof value.type !== "string") { - return false; - } - - switch (value.type) { - case "json": - return "value" in value; - case "text": - case "error-text": - return typeof value.value === "string"; - default: - return false; - } -} - -function isKeepableModelPart( - part: unknown, - role: ProviderModelMessage["role"], - includeReasoning: boolean, -): boolean { - if (!isRecord(part) || typeof part.type !== "string") return false; - - switch (part.type) { - case "text": - return role !== "tool" && hasNonEmptyStringField(part, "text"); - case "reasoning": - return role === "assistant" && - includeReasoning && - ( - hasNonEmptyStringField(part, "text") || - hasNonEmptyStringField(part, "signature") || - hasNonEmptyStringField(part, "redactedData") - ); - case "tool-call": - return role === "assistant" && - hasNonEmptyStringField(part, "toolCallId") && - hasNonEmptyStringField(part, "toolName") && - isRecord(part.input) && - (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); - case "tool-result": - return (role === "assistant" || role === "tool") && - hasNonEmptyStringField(part, "toolCallId") && - hasNonEmptyStringField(part, "toolName") && - hasValidToolResultOutput(part.output) && - (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); - case "image": - case "file": { - if ( - role === "system" || - role === "tool" || - !hasNonEmptyStringField(part, "mediaType") || - (!hasNonEmptyStringField(part, "data") && !hasNonEmptyStringField(part, "url")) - ) { - return false; - } - - const url = typeof part.url === "string" ? part.url : ""; - if (url.startsWith("data:image/") && part.filename === "preview-screenshot.png") { - return false; - } - return true; - } - default: - return false; - } -} - -function hasValidContent(message: ProviderModelMessage): boolean { - const content = message.content; - - if (content === undefined || content === null) return false; - if (typeof content === "string") { - return message.role !== "tool" && content.trim().length > 0; - } - if (Array.isArray(content)) return cleanContent(content, message.role).length > 0; - return false; -} - -function cleanContent(content: T[], role: ProviderModelMessage["role"]): T[] { - const hasSubstantiveContent = content.some((part) => isKeepableModelPart(part, role, false)); - return filterPrivateArray( - content, - (part) => isKeepableModelPart(part, role, hasSubstantiveContent), - ); -} - /** Sanitize provider model messages. */ export function sanitizeProviderModelMessages( messages: ProviderModelMessage[], diff --git a/src/chat/provider-message-content.ts b/src/chat/provider-message-content.ts new file mode 100644 index 0000000000..06a530826a --- /dev/null +++ b/src/chat/provider-message-content.ts @@ -0,0 +1,94 @@ +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; +import { isRecord } from "./part-field-access.ts"; +import type { ProviderModelMessage } from "./types.ts"; + +function hasNonEmptyStringField(record: Record, key: string): boolean { + return typeof record[key] === "string" && record[key].trim().length > 0; +} + +function hasValidToolResultOutput(value: unknown): boolean { + if (!isRecord(value) || typeof value.type !== "string") { + return false; + } + + switch (value.type) { + case "json": + return "value" in value; + case "text": + case "error-text": + return typeof value.value === "string"; + default: + return false; + } +} + +function isKeepableModelPart( + part: unknown, + role: ProviderModelMessage["role"], + includeReasoning: boolean, +): boolean { + if (!isRecord(part) || typeof part.type !== "string") return false; + + switch (part.type) { + case "text": + return role !== "tool" && hasNonEmptyStringField(part, "text"); + case "reasoning": + return role === "assistant" && + includeReasoning && + ( + hasNonEmptyStringField(part, "text") || + hasNonEmptyStringField(part, "signature") || + hasNonEmptyStringField(part, "redactedData") + ); + case "tool-call": + return role === "assistant" && + hasNonEmptyStringField(part, "toolCallId") && + hasNonEmptyStringField(part, "toolName") && + isRecord(part.input) && + (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); + case "tool-result": + return (role === "assistant" || role === "tool") && + hasNonEmptyStringField(part, "toolCallId") && + hasNonEmptyStringField(part, "toolName") && + hasValidToolResultOutput(part.output) && + (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); + case "image": + case "file": { + if ( + role === "system" || + role === "tool" || + !hasNonEmptyStringField(part, "mediaType") || + (!hasNonEmptyStringField(part, "data") && !hasNonEmptyStringField(part, "url")) + ) { + return false; + } + + const url = typeof part.url === "string" ? part.url : ""; + if (url.startsWith("data:image/") && part.filename === "preview-screenshot.png") { + return false; + } + return true; + } + default: + return false; + } +} + +export function hasValidContent(message: ProviderModelMessage): boolean { + const content = message.content; + + if (content === undefined || content === null) return false; + if (typeof content === "string") { + return message.role !== "tool" && content.trim().length > 0; + } + if (Array.isArray(content)) return cleanContent(content, message.role).length > 0; + return false; +} + +export function cleanContent(content: T[], role: ProviderModelMessage["role"]): T[] { + const hasSubstantiveContent = content.some((part) => isKeepableModelPart(part, role, false)); + return filterPrivateArray( + content, + (part) => isKeepableModelPart(part, role, hasSubstantiveContent), + ); +} diff --git a/src/security/private-map.test.ts b/src/security/private-map.test.ts new file mode 100644 index 0000000000..2798798cbe --- /dev/null +++ b/src/security/private-map.test.ts @@ -0,0 +1,53 @@ +import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createPrivateMap } from "./private-map.ts"; + +describe("private maps", () => { + it("preserves identity, insertion order, updates, and deletion through bound operations", () => { + const map = createPrivateMap(); + const first = { text: "first" }; + const second = { text: "second" }; + const { set, get, has, delete: remove, clear, values, keys, entries } = map; + assertStrictEquals(set("first", first), map); + set("second", second); + set("first", second); + assertStrictEquals(get("first"), second); + assertEquals(has("second"), true); + assertEquals(map.size, 2); + assertEquals([...keys()], ["first", "second"]); + assertEquals([...values()], [second, second]); + assertEquals([...entries()], [["first", second], ["second", second]]); + assertEquals([...map], [...entries()]); + const context = {}; + const visited: string[] = []; + map.forEach(function (this: unknown, value, key, owner) { + assertStrictEquals(this, context); + assertStrictEquals(owner, map); + assertStrictEquals(value, second); + visited.push(key); + }, context); + assertEquals(visited, ["first", "second"]); + assertEquals(remove("first"), true); + assertEquals(remove("missing"), false); + assertEquals(get("first"), undefined); + clear(); + assertEquals(map.size, 0); + assertEquals([...values()], []); + }); + + it("retains native live iteration without writable operation or iterator lookups", () => { + const map = createPrivateMap(); + map.set("first", 1); + const iterator = map.values(); + assertEquals(iterator.next(), { done: false, value: 1 }); + map.set("second", 2); + assertEquals(iterator.next(), { done: false, value: 2 }); + assertEquals(iterator.next(), { done: true, value: undefined }); + assertEquals(Object.getPrototypeOf(iterator), null); + assertEquals(Object.isFrozen(iterator), true); + assertEquals(Object.isFrozen(map), true); + assertEquals(Object.hasOwn(map, "get"), true); + assertEquals(Object.hasOwn(map, "set"), true); + assertEquals(Object.hasOwn(map, "values"), true); + }); +}); diff --git a/src/security/private-map.ts b/src/security/private-map.ts new file mode 100644 index 0000000000..3c7f138d66 --- /dev/null +++ b/src/security/private-map.ts @@ -0,0 +1,57 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const MapConstructor = Map; +const apply = Reflect.apply; +const defineProperty = Object.defineProperty; +const freeze = Object.freeze; +const mapGet = Map.prototype.get; +const mapSet = Map.prototype.set; +const mapHas = Map.prototype.has; +const mapDelete = Map.prototype.delete; +const mapClear = Map.prototype.clear; +const mapForEach = Map.prototype.forEach; +const mapValues = Map.prototype.values; +const mapKeys = Map.prototype.keys; +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!; + +/** A private map with captured construction, operations, and iterator advancement. */ +export function createPrivateMap(): Map { + const map = new MapConstructor(); + const iterate = (method: (this: Map) => IterableIterator) => { + const iterator = apply(method, map, []); + return freeze({ + __proto__: null, + next: () => apply(iteratorNext, iterator, []) as IteratorResult, + [iteratorSymbol]() { + return this; + }, + }); + }; + defineOwnDataProperty(map, "get", (key: K) => apply(mapGet, map, [key]) as V | undefined); + defineOwnDataProperty(map, "set", (key: K, value: V) => { + apply(mapSet, map, [key, value]); + return map; + }); + defineOwnDataProperty(map, "has", (key: K) => apply(mapHas, map, [key]) as boolean); + defineOwnDataProperty(map, "delete", (key: K) => apply(mapDelete, map, [key]) as boolean); + defineOwnDataProperty(map, "clear", () => { + apply(mapClear, map, []); + }); + defineOwnDataProperty(map, "values", () => iterate(mapValues)); + defineOwnDataProperty(map, "keys", () => iterate(mapKeys)); + defineOwnDataProperty(map, "entries", () => iterate(mapEntries)); + defineOwnDataProperty(map, iteratorSymbol, () => iterate(mapEntries)); + defineOwnDataProperty( + map, + "forEach", + (callback: (value: V, key: K, map: Map) => void, thisArg?: unknown) => { + apply(mapForEach, map, [(value: V, key: K) => apply(callback, thisArg, [value, key, map])]); + }, + ); + const sizeDescriptor = { __proto__: null, get: () => apply(mapSize, map, []) as number }; + defineProperty(map, "size", sizeDescriptor); + return freeze(map); +} diff --git a/src/security/private-set.test.ts b/src/security/private-set.test.ts index c17872bdd7..89b8dfcc7b 100644 --- a/src/security/private-set.test.ts +++ b/src/security/private-set.test.ts @@ -3,6 +3,34 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { createPrivateSet } from "./private-set.ts"; describe("private selector sets", () => { + it("reads only own array entries without invoking inherited accessors or iteration", () => { + const first = { text: "synthetic input" }; + const source = [first, , undefined]; + let observations = 0; + Object.setPrototypeOf( + source, + Object.create(Array.prototype, { + 1: { + get() { + observations++; + return { text: "inherited" }; + }, + }, + [Symbol.iterator]: { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }, + }), + ); + const values = createPrivateSet(source); + assertEquals(values.has(first), true); + assertEquals(values.has(undefined), true); + assertEquals(values.size, 2); + assertEquals(observations, 0); + }); + it("snapshots caller-owned arrays and sets without sharing later membership changes", () => { const source = ["read_file", "read_file"]; const first = createPrivateSet(source); diff --git a/src/security/private-set.ts b/src/security/private-set.ts index aa352aa581..6e4f5db1ee 100644 --- a/src/security/private-set.ts +++ b/src/security/private-set.ts @@ -5,6 +5,7 @@ const apply = Reflect.apply; const defineProperty = Object.defineProperty; const freeze = Object.freeze; const isArray = Array.isArray; +const hasOwn = Object.hasOwn; const setAdd = Set.prototype.add; const setHas = Set.prototype.has; const setDelete = Set.prototype.delete; @@ -44,7 +45,9 @@ export function createPrivateSet(values?: Iterable): Set { const sizeDescriptor = { __proto__: null, get: () => apply(setSize, set, []) as number }; defineProperty(set, "size", sizeDescriptor); if (isArray(values)) { - for (let index = 0; index < values.length; index++) add(values[index]); + for (let index = 0; index < values.length; index++) { + if (hasOwn(values, index)) add(values[index]); + } } else if (values) { for (const value of values) add(value); } From 301c4712ee4c996c03ef2d7bb0d0ada99df31890 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 09:46:20 +0200 Subject: [PATCH 126/194] fix(agent): retain markdown adapter output type context --- src/agent/runtime/agent-markdown-adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent/runtime/agent-markdown-adapter.ts b/src/agent/runtime/agent-markdown-adapter.ts index f5de185cb1..8442e85098 100644 --- a/src/agent/runtime/agent-markdown-adapter.ts +++ b/src/agent/runtime/agent-markdown-adapter.ts @@ -45,7 +45,7 @@ export function createRuntimeAgentFromMarkdownDefinition( !deniedToolNames.has(`${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}`), ); - const runtimeAgent = agent({ + const runtimeAgent: Agent = agent({ id: definition.id, name: definition.name, description: definition.description, From 54bd386c469d340faf27c8387d0e604b376a3485 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 09:52:29 +0200 Subject: [PATCH 127/194] fix(deps): update sharp to patched libheif binaries --- deno.lock | 182 +++++++++---------- extensions/ext-image-sharp/deno.json | 2 +- extensions/ext-image-sharp/src/index.test.ts | 2 +- 3 files changed, 93 insertions(+), 93 deletions(-) diff --git a/deno.lock b/deno.lock index b55236ca44..38fa6f7fd6 100644 --- a/deno.lock +++ b/deno.lock @@ -114,7 +114,7 @@ "npm:remark-gfm@4.0.1": "4.0.1", "npm:remark-parse@11.0.0": "11.0.0", "npm:remark-rehype@11.1.2": "11.1.2", - "npm:sharp@0.35.3": "0.35.3", + "npm:sharp@0.35.4": "0.35.4", "npm:tailwind-scrollbar-hide@2.0.0": "2.0.0_tailwindcss@4.2.2", "npm:tailwindcss-animate@1.0.7": "1.0.7_tailwindcss@4.2.2", "npm:tailwindcss@4.2.2": "4.2.2", @@ -1002,10 +1002,10 @@ "os": ["darwin"], "cpu": ["arm64"] }, - "@img/sharp-darwin-arm64@0.35.3": { - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "@img/sharp-darwin-arm64@0.35.4": { + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "optionalDependencies": [ - "@img/sharp-libvips-darwin-arm64@1.3.2" + "@img/sharp-libvips-darwin-arm64@1.3.3" ], "os": ["darwin"], "cpu": ["arm64"] @@ -1018,18 +1018,18 @@ "os": ["darwin"], "cpu": ["x64"] }, - "@img/sharp-darwin-x64@0.35.3": { - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "@img/sharp-darwin-x64@0.35.4": { + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "optionalDependencies": [ - "@img/sharp-libvips-darwin-x64@1.3.2" + "@img/sharp-libvips-darwin-x64@1.3.3" ], "os": ["darwin"], "cpu": ["x64"] }, - "@img/sharp-freebsd-wasm32@0.35.3": { - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "@img/sharp-freebsd-wasm32@0.35.4": { + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "dependencies": [ - "@img/sharp-wasm32@0.35.3" + "@img/sharp-wasm32@0.35.4" ], "os": ["freebsd"] }, @@ -1038,8 +1038,8 @@ "os": ["darwin"], "cpu": ["arm64"] }, - "@img/sharp-libvips-darwin-arm64@1.3.2": { - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "@img/sharp-libvips-darwin-arm64@1.3.3": { + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "os": ["darwin"], "cpu": ["arm64"] }, @@ -1048,8 +1048,8 @@ "os": ["darwin"], "cpu": ["x64"] }, - "@img/sharp-libvips-darwin-x64@1.3.2": { - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "@img/sharp-libvips-darwin-x64@1.3.3": { + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "os": ["darwin"], "cpu": ["x64"] }, @@ -1058,8 +1058,8 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-libvips-linux-arm64@1.3.2": { - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "@img/sharp-libvips-linux-arm64@1.3.3": { + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "os": ["linux"], "cpu": ["arm64"] }, @@ -1068,8 +1068,8 @@ "os": ["linux"], "cpu": ["arm"] }, - "@img/sharp-libvips-linux-arm@1.3.2": { - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "@img/sharp-libvips-linux-arm@1.3.3": { + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "os": ["linux"], "cpu": ["arm"] }, @@ -1078,8 +1078,8 @@ "os": ["linux"], "cpu": ["ppc64"] }, - "@img/sharp-libvips-linux-ppc64@1.3.2": { - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "@img/sharp-libvips-linux-ppc64@1.3.3": { + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "os": ["linux"], "cpu": ["ppc64"] }, @@ -1088,8 +1088,8 @@ "os": ["linux"], "cpu": ["riscv64"] }, - "@img/sharp-libvips-linux-riscv64@1.3.2": { - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "@img/sharp-libvips-linux-riscv64@1.3.3": { + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "os": ["linux"], "cpu": ["riscv64"] }, @@ -1098,8 +1098,8 @@ "os": ["linux"], "cpu": ["s390x"] }, - "@img/sharp-libvips-linux-s390x@1.3.2": { - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "@img/sharp-libvips-linux-s390x@1.3.3": { + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "os": ["linux"], "cpu": ["s390x"] }, @@ -1108,8 +1108,8 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-libvips-linux-x64@1.3.2": { - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "@img/sharp-libvips-linux-x64@1.3.3": { + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "os": ["linux"], "cpu": ["x64"] }, @@ -1118,8 +1118,8 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-libvips-linuxmusl-arm64@1.3.2": { - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "@img/sharp-libvips-linuxmusl-arm64@1.3.3": { + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "os": ["linux"], "cpu": ["arm64"] }, @@ -1128,8 +1128,8 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-libvips-linuxmusl-x64@1.3.2": { - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "@img/sharp-libvips-linuxmusl-x64@1.3.3": { + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "os": ["linux"], "cpu": ["x64"] }, @@ -1141,10 +1141,10 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-linux-arm64@0.35.3": { - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "@img/sharp-linux-arm64@0.35.4": { + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-arm64@1.3.2" + "@img/sharp-libvips-linux-arm64@1.3.3" ], "os": ["linux"], "cpu": ["arm64"] @@ -1157,10 +1157,10 @@ "os": ["linux"], "cpu": ["arm"] }, - "@img/sharp-linux-arm@0.35.3": { - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "@img/sharp-linux-arm@0.35.4": { + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "optionalDependencies": [ - "@img/sharp-libvips-linux-arm@1.3.2" + "@img/sharp-libvips-linux-arm@1.3.3" ], "os": ["linux"], "cpu": ["arm"] @@ -1173,10 +1173,10 @@ "os": ["linux"], "cpu": ["ppc64"] }, - "@img/sharp-linux-ppc64@0.35.3": { - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "@img/sharp-linux-ppc64@0.35.4": { + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-ppc64@1.3.2" + "@img/sharp-libvips-linux-ppc64@1.3.3" ], "os": ["linux"], "cpu": ["ppc64"] @@ -1189,10 +1189,10 @@ "os": ["linux"], "cpu": ["riscv64"] }, - "@img/sharp-linux-riscv64@0.35.3": { - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "@img/sharp-linux-riscv64@0.35.4": { + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "optionalDependencies": [ - "@img/sharp-libvips-linux-riscv64@1.3.2" + "@img/sharp-libvips-linux-riscv64@1.3.3" ], "os": ["linux"], "cpu": ["riscv64"] @@ -1205,10 +1205,10 @@ "os": ["linux"], "cpu": ["s390x"] }, - "@img/sharp-linux-s390x@0.35.3": { - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "@img/sharp-linux-s390x@0.35.4": { + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-s390x@1.3.2" + "@img/sharp-libvips-linux-s390x@1.3.3" ], "os": ["linux"], "cpu": ["s390x"] @@ -1221,10 +1221,10 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-linux-x64@0.35.3": { - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "@img/sharp-linux-x64@0.35.4": { + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "optionalDependencies": [ - "@img/sharp-libvips-linux-x64@1.3.2" + "@img/sharp-libvips-linux-x64@1.3.3" ], "os": ["linux"], "cpu": ["x64"] @@ -1237,10 +1237,10 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-linuxmusl-arm64@0.35.3": { - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "@img/sharp-linuxmusl-arm64@0.35.4": { + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "optionalDependencies": [ - "@img/sharp-libvips-linuxmusl-arm64@1.3.2" + "@img/sharp-libvips-linuxmusl-arm64@1.3.3" ], "os": ["linux"], "cpu": ["arm64"] @@ -1253,10 +1253,10 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-linuxmusl-x64@0.35.3": { - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "@img/sharp-linuxmusl-x64@0.35.4": { + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "optionalDependencies": [ - "@img/sharp-libvips-linuxmusl-x64@1.3.2" + "@img/sharp-libvips-linuxmusl-x64@1.3.3" ], "os": ["linux"], "cpu": ["x64"] @@ -1268,16 +1268,16 @@ ], "cpu": ["wasm32"] }, - "@img/sharp-wasm32@0.35.3": { - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "@img/sharp-wasm32@0.35.4": { + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dependencies": [ "@emnapi/runtime@1.11.3" ] }, - "@img/sharp-webcontainers-wasm32@0.35.3": { - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "@img/sharp-webcontainers-wasm32@0.35.4": { + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "dependencies": [ - "@img/sharp-wasm32@0.35.3" + "@img/sharp-wasm32@0.35.4" ], "cpu": ["wasm32"] }, @@ -1286,8 +1286,8 @@ "os": ["win32"], "cpu": ["arm64"] }, - "@img/sharp-win32-arm64@0.35.3": { - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "@img/sharp-win32-arm64@0.35.4": { + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "os": ["win32"], "cpu": ["arm64"] }, @@ -1296,8 +1296,8 @@ "os": ["win32"], "cpu": ["ia32"] }, - "@img/sharp-win32-ia32@0.35.3": { - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "@img/sharp-win32-ia32@0.35.4": { + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "os": ["win32"], "cpu": ["ia32"] }, @@ -1306,8 +1306,8 @@ "os": ["win32"], "cpu": ["x64"] }, - "@img/sharp-win32-x64@0.35.3": { - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "@img/sharp-win32-x64@0.35.4": { + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "os": ["win32"], "cpu": ["x64"] }, @@ -5172,39 +5172,39 @@ ], "scripts": true }, - "sharp@0.35.3": { - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "sharp@0.35.4": { + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dependencies": [ "@img/colour", "detect-libc", "semver" ], "optionalDependencies": [ - "@img/sharp-darwin-arm64@0.35.3", - "@img/sharp-darwin-x64@0.35.3", + "@img/sharp-darwin-arm64@0.35.4", + "@img/sharp-darwin-x64@0.35.4", "@img/sharp-freebsd-wasm32", - "@img/sharp-libvips-darwin-arm64@1.3.2", - "@img/sharp-libvips-darwin-x64@1.3.2", - "@img/sharp-libvips-linux-arm@1.3.2", - "@img/sharp-libvips-linux-arm64@1.3.2", - "@img/sharp-libvips-linux-ppc64@1.3.2", - "@img/sharp-libvips-linux-riscv64@1.3.2", - "@img/sharp-libvips-linux-s390x@1.3.2", - "@img/sharp-libvips-linux-x64@1.3.2", - "@img/sharp-libvips-linuxmusl-arm64@1.3.2", - "@img/sharp-libvips-linuxmusl-x64@1.3.2", - "@img/sharp-linux-arm@0.35.3", - "@img/sharp-linux-arm64@0.35.3", - "@img/sharp-linux-ppc64@0.35.3", - "@img/sharp-linux-riscv64@0.35.3", - "@img/sharp-linux-s390x@0.35.3", - "@img/sharp-linux-x64@0.35.3", - "@img/sharp-linuxmusl-arm64@0.35.3", - "@img/sharp-linuxmusl-x64@0.35.3", + "@img/sharp-libvips-darwin-arm64@1.3.3", + "@img/sharp-libvips-darwin-x64@1.3.3", + "@img/sharp-libvips-linux-arm@1.3.3", + "@img/sharp-libvips-linux-arm64@1.3.3", + "@img/sharp-libvips-linux-ppc64@1.3.3", + "@img/sharp-libvips-linux-riscv64@1.3.3", + "@img/sharp-libvips-linux-s390x@1.3.3", + "@img/sharp-libvips-linux-x64@1.3.3", + "@img/sharp-libvips-linuxmusl-arm64@1.3.3", + "@img/sharp-libvips-linuxmusl-x64@1.3.3", + "@img/sharp-linux-arm@0.35.4", + "@img/sharp-linux-arm64@0.35.4", + "@img/sharp-linux-ppc64@0.35.4", + "@img/sharp-linux-riscv64@0.35.4", + "@img/sharp-linux-s390x@0.35.4", + "@img/sharp-linux-x64@0.35.4", + "@img/sharp-linuxmusl-arm64@0.35.4", + "@img/sharp-linuxmusl-x64@0.35.4", "@img/sharp-webcontainers-wasm32", - "@img/sharp-win32-arm64@0.35.3", - "@img/sharp-win32-ia32@0.35.3", - "@img/sharp-win32-x64@0.35.3" + "@img/sharp-win32-arm64@0.35.4", + "@img/sharp-win32-ia32@0.35.4", + "@img/sharp-win32-x64@0.35.4" ] }, "simple-concat@1.0.1": { @@ -6561,7 +6561,7 @@ "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", - "npm:sharp@0.35.3" + "npm:sharp@0.35.4" ] }, "extensions/ext-llm-anthropic": { diff --git a/extensions/ext-image-sharp/deno.json b/extensions/ext-image-sharp/deno.json index 4b1063ccb9..84f83756c8 100644 --- a/extensions/ext-image-sharp/deno.json +++ b/extensions/ext-image-sharp/deno.json @@ -21,7 +21,7 @@ ] }, "imports": { - "sharp": "npm:sharp@0.35.3", + "sharp": "npm:sharp@0.35.4", "@std/assert": "jsr:@std/assert@1.0.19", "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", "veryfront/extensions": "../../src/extensions/types.ts", diff --git a/extensions/ext-image-sharp/src/index.test.ts b/extensions/ext-image-sharp/src/index.test.ts index 5ea744979c..e9b0aa13c2 100644 --- a/extensions/ext-image-sharp/src/index.test.ts +++ b/extensions/ext-image-sharp/src/index.test.ts @@ -54,7 +54,7 @@ describe("ext-image-sharp", () => { it("keeps cache identity immutable and backend-specific", () => { const engine = new SharpImageOptimizationEngine(); - assertStringIncludes(engine.cacheIdentity, "sharp@0.35.3"); + assertStringIncludes(engine.cacheIdentity, "sharp@0.35.4"); assertStringIncludes(engine.cacheIdentity, `vips@${sharp.versions.vips}`); assertEquals(Object.isFrozen(engine), true); assertThrows( From 5c5ca08e0e836f323279bdd71111ba5f7c674cf7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:05:34 +0200 Subject: [PATCH 128/194] fix(agent): protect provider conversion text and array reads --- ...neration-runtime-message-converter.test.ts | 20 ++++ ...xt-generation-runtime-message-converter.ts | 106 ++++++++++++------ src/security/private-text.ts | 5 + ...tor-provider-conversion-intrinsics.test.ts | 98 ++++++++++++++++ 4 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 tests/integration/agent/executor-provider-conversion-intrinsics.test.ts diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 1f1d280395..2607669c20 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -16,6 +16,26 @@ import type { Message } from "../types.ts"; import { attachProviderMetadata, markProviderReplayDelivered } from "./provider-metadata.ts"; describe("text-generation-runtime-message-converter", () => { + it("converts history without consulting caller-owned part iterators", () => { + const parts: Message["parts"] = [{ type: "text", text: "Synthetic model text" }]; + let observations = 0; + Object.defineProperty(parts, Symbol.iterator, { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }); + const messages: Message[] = [ + { id: "user", role: "user", parts: [{ type: "text", text: "Synthetic prompt" }] }, + { id: "assistant", role: "assistant", parts }, + ]; + assertEquals(convertToTextGenerationRuntimeMessages(messages), [ + { role: "user", content: "Synthetic prompt" }, + { role: "assistant", content: [{ type: "text", text: "Synthetic model text" }] }, + ]); + assertEquals(observations, 0); + }); + describe("convertToTextGenerationRuntimeMessage", () => { it("converts a system message", () => { const msg: Message = { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 8371accb56..abc5ee4104 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -1,6 +1,8 @@ +import { privateTextIncludes, privateTextStartsWith } from "#veryfront/security/private-text.ts"; import { appendPrivateArray, flatMapPrivateArray, + mapPrivateArray, pushPrivateArray, } from "#veryfront/security/private-array.ts"; /** @@ -34,6 +36,8 @@ import { groupAnthropicRawAssistantMessagesByAnchor, } from "./anthropic-provider-replay-block.ts"; +const hasOwn = Object.hasOwn; + function getStringPartField(part: unknown, key: string): string | undefined { if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; @@ -103,7 +107,9 @@ function consumeProviderExecutedToolResults( message: Message, providerExecutedToolCallIds: Set, ): void { - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; shouldSkipProviderExecutedToolResult(part, providerExecutedToolCallIds); } } @@ -123,7 +129,7 @@ function getTextGenerationToolCallPart( if ( part.type !== "tool_call" && part.type !== "tool-call" && - !(part.type.startsWith("tool-") && part.type !== "tool-result") + !(privateTextStartsWith(part.type, "tool-") && part.type !== "tool-result") ) { return null; } @@ -131,7 +137,7 @@ function getTextGenerationToolCallPart( const toolName = getStringPartField(part, "toolName") ?? getStringPartField(part, "tool_name") ?? getStringPartField(part, "name") ?? - (part.type.startsWith("tool-") && part.type !== "tool-call" + (privateTextStartsWith(part.type, "tool-") && part.type !== "tool-call" ? part.type.replace(/^tool-/, "") : undefined); @@ -195,7 +201,7 @@ function getTextGenerationToolResultPart( /** @internal Provider-visible text annotation shared with input validation. */ export function buildAttachmentContextFromParts(parts: Message["parts"]): string { - if (getTextFromParts(parts).includes("")) return ""; + if (privateTextIncludes(getTextFromParts(parts), "")) return ""; const refs = flatMapPrivateArray(parts, (part) => { const type = getStringPartField(part, "type"); if (type !== "file" && type !== "image") return []; @@ -214,7 +220,7 @@ export function buildAttachmentContextFromParts(parts: Message["parts"]): string ...(uploadPath ? { path: uploadPath } : {}), // Never inline a `data:` URL here — it would dump the whole base64 blob // into the prompt as text. The bytes ride in the native file part below. - ...(url && !url.startsWith("data:") ? { url } : {}), + ...(url && !privateTextStartsWith(url, "data:") ? { url } : {}), }]; }); @@ -238,18 +244,19 @@ function appendReadableAttachmentContext(text: string, attachmentContext: string /** @internal Exact text projection shared with input validation. */ export function getUserTextWithAttachmentContext(parts: Message["parts"]): string { const text = getTextFromParts(parts); - return text.includes("") + return privateTextIncludes(text, "") ? text : appendReadableAttachmentContext(text, buildAttachmentContextFromParts(parts)); } /** @internal Text metadata sent in native attachment parts, independent of annotations. */ export function getProviderAttachmentMetadata(parts: Message["parts"]): string[] { - return getUserFileParts(parts, false).flatMap((part) => [ - part.mediaType, - ...(part.filename ? [part.filename] : []), - ...(part.url.startsWith("data:") ? [] : [part.url]), - ]); + return flatMapPrivateArray(getUserFileParts(parts, false), (part) => { + const metadata = [part.mediaType]; + if (part.filename) pushPrivateArray(metadata, part.filename); + if (!privateTextStartsWith(part.url, "data:")) pushPrivateArray(metadata, part.url); + return metadata; + }); } function getUserFileParts( @@ -329,25 +336,24 @@ export function convertToTextGenerationRuntimeMessage( } const text = getTextFromParts(msg.parts); - const attachmentContext = text.includes("") + const attachmentContext = privateTextIncludes(text, "") ? "" : buildAttachmentContextFromParts(msg.parts); - return { - role: "user", - content: [ - ...(text.length > 0 ? [{ type: "text" as const, text }] : []), - ...fileParts, - ...(attachmentContext.length > 0 - ? [{ type: "text" as const, text: attachmentContext.trimStart() }] - : []), - ], - }; + const content: Array = []; + if (text.length > 0) pushPrivateArray(content, { type: "text", text }); + appendPrivateArray(content, fileParts); + if (attachmentContext.length > 0) { + pushPrivateArray(content, { type: "text", text: attachmentContext.trimStart() }); + } + return { role: "user", content }; } case "assistant": { const content: Array = []; - for (const part of msg.parts) { + for (let partIndex = 0; partIndex < msg.parts.length; partIndex++) { + if (!hasOwn(msg.parts, partIndex)) continue; + const part = msg.parts[partIndex]!; if (part.type === "text" && "text" in part) { pushPrivateArray(content, { type: "text", text: (part as { text: string }).text }); continue; @@ -384,7 +390,9 @@ export function convertToTextGenerationRuntimeMessage( const content: TextGenerationRuntimeToolMessage["content"] = []; const toolNamesById = new Map(); - for (const part of msg.parts) { + for (let partIndex = 0; partIndex < msg.parts.length; partIndex++) { + if (!hasOwn(msg.parts, partIndex)) continue; + const part = msg.parts[partIndex]!; if ( shouldSkipProviderExecutedToolResult(part, providerExecutedToolCallIds) ) { @@ -430,12 +438,16 @@ export function hasProviderSendableAssistantContent( // duplicate ID cannot make the predicate claim content that conversion will // remove. const providerExecutedToolCallIds = new Set(priorProviderExecutedToolCallIds); - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; const toolCallId = getProviderExecutedToolCallId(part); if (toolCallId) providerExecutedToolCallIds.add(toolCallId); } - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; if (part.type === "text" && "text" in part) { if ( typeof (part as { text?: unknown }).text === "string" && @@ -462,7 +474,9 @@ export function getProviderSendableAssistantMessages( providerExecutedToolCallIds.clear(); } addProviderMetadataToolCallIds(message, providerExecutedToolCallIds); - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; const providerExecutedToolCallId = getProviderExecutedToolCallId(part); if (providerExecutedToolCallId) providerExecutedToolCallIds.add(providerExecutedToolCallId); } @@ -479,7 +493,9 @@ export function getProviderSendableAssistantMessages( continue; } if (message.role === "tool") { - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; shouldSkipProviderExecutedToolResult(part, providerExecutedToolCallIds); } } @@ -499,7 +515,9 @@ export function getProviderSendableToolMessages( providerExecutedToolCallIds.clear(); } addProviderMetadataToolCallIds(message, providerExecutedToolCallIds); - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; const providerExecutedToolCallId = getProviderExecutedToolCallId(part); if (providerExecutedToolCallId) providerExecutedToolCallIds.add(providerExecutedToolCallId); } @@ -540,7 +558,9 @@ export function getAnthropicCompactedAssistantMessages( providerExecutedToolCallIds.clear(); } addProviderMetadataToolCallIds(message, providerExecutedToolCallIds); - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; const providerExecutedToolCallId = getProviderExecutedToolCallId(part); if (providerExecutedToolCallId) providerExecutedToolCallIds.add(providerExecutedToolCallId); } @@ -559,7 +579,9 @@ export function getAnthropicCompactedAssistantMessages( compacted.add(message); } if (message.role === "tool") { - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; shouldSkipProviderExecutedToolResult(part, providerExecutedToolCallIds); } } @@ -600,7 +622,10 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( return; } - pushPrivateArray(messages, { role: "assistant", content: [...content] }); + pushPrivateArray(messages, { + role: "assistant", + content: mapPrivateArray(content, (part) => part), + }); content.length = 0; }; @@ -609,7 +634,10 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( return; } - pushPrivateArray(messages, { role: "tool", content: [...toolResults] }); + pushPrivateArray(messages, { + role: "tool", + content: mapPrivateArray(toolResults, (part) => part), + }); toolResults.length = 0; }; @@ -654,7 +682,9 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( pendingToolCallIds.delete(part.toolCallId); }; - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; const providerExecutedToolCallId = getProviderExecutedToolCallId(part); if (providerExecutedToolCallId) { providerExecutedToolCallIds.add(providerExecutedToolCallId); @@ -743,7 +773,9 @@ export function convertToTextGenerationRuntimeMessages( } addProviderMetadataToolCallIds(message, providerExecutedToolCallIds); - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; const providerExecutedToolCallId = getProviderExecutedToolCallId(part); if (providerExecutedToolCallId) { providerExecutedToolCallIds.add(providerExecutedToolCallId); @@ -762,7 +794,9 @@ export function convertToTextGenerationRuntimeMessages( ...options, })]; - for (const convertedMessage of convertedMessages) { + for (let convertedIndex = 0; convertedIndex < convertedMessages.length; convertedIndex++) { + if (!hasOwn(convertedMessages, convertedIndex)) continue; + const convertedMessage = convertedMessages[convertedIndex]!; if (convertedMessage.role === "tool" && convertedMessage.content.length === 0) { continue; } diff --git a/src/security/private-text.ts b/src/security/private-text.ts index bf6988afb5..67948c6419 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -1,6 +1,11 @@ const apply = Reflect.apply; const startsWith = String.prototype.startsWith; const slice = String.prototype.slice; +const includes = String.prototype.includes; + +export function privateTextIncludes(value: string, search: string): boolean { + return apply(includes, value, [search]) as boolean; +} export function privateTextStartsWith(value: string, search: string): boolean { return apply(startsWith, value, [search]) as boolean; diff --git a/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts b/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts new file mode 100644 index 0000000000..bbfc0ac951 --- /dev/null +++ b/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts @@ -0,0 +1,98 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { Message } from "#veryfront/agent/types.ts"; +import { convertToTextGenerationRuntimeMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; + +for (const replaceMethods of [false, true]) { + describe(`private provider conversion ${replaceMethods ? "hooks" : "baseline"}`, () => { + it("preserves prompt, assistant, and tool segments without observable lookups", () => { + const prompt = "synthetic-private-conversion-prompt"; + const modelText = "synthetic-private-conversion-output"; + const argumentsValue = "synthetic-private-conversion-arguments"; + const resultValue = "synthetic-private-conversion-result"; + const messages: Message[] = [ + { id: "user", role: "user", parts: [{ type: "text", text: prompt }] }, + { + id: "assistant", + role: "assistant", + parts: [ + { type: "text", text: modelText }, + { + type: "tool-call", + toolCallId: "call", + toolName: "local_tool", + input: { query: argumentsValue }, + }, + { + type: "tool-result", + toolCallId: "call", + toolName: "local_tool", + result: resultValue, + }, + { type: "text", text: "Complete" }, + ], + }, + ]; + const includes = String.prototype.includes; + const iterate = Array.prototype[Symbol.iterator]; + const apply = Reflect.apply; + let textObservations = 0; + let arrayObservations = 0; + let converted: ReturnType = []; + try { + if (replaceMethods) { + String.prototype.includes = function (search, position) { + if (this === prompt) textObservations++; + return apply(includes, this, [search, position]); + }; + Array.prototype[Symbol.iterator] = function () { + for (let index = 0; index < this.length; index++) { + const part = this[index]; + if ( + part && typeof part === "object" && ( + part.text === prompt || part.text === modelText || + part.input?.query === argumentsValue || part.output?.value === resultValue + ) + ) arrayObservations++; + } + return apply(iterate, this, []); + }; + } + converted = convertToTextGenerationRuntimeMessages(messages); + } finally { + if (replaceMethods) { + String.prototype.includes = includes; + Array.prototype[Symbol.iterator] = iterate; + } + } + assertEquals(converted, [ + { role: "user", content: prompt }, + { + role: "assistant", + content: [ + { type: "text", text: modelText }, + { + type: "tool-call", + toolCallId: "call", + toolName: "local_tool", + input: { query: argumentsValue }, + }, + ], + }, + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "call", + toolName: "local_tool", + output: { type: "json", value: resultValue }, + }], + }, + { role: "assistant", content: [{ type: "text", text: "Complete" }] }, + ]); + assertEquals(textObservations, 0); + assertEquals(arrayObservations, 0); + }); + }); +} From 08c8f7a49edde1e86788e117c390c5c37c482d6b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 09:52:29 +0200 Subject: [PATCH 129/194] fix(deps): update sharp to patched libheif binaries --- deno.lock | 182 +++++++++---------- extensions/ext-image-sharp/deno.json | 2 +- extensions/ext-image-sharp/src/index.test.ts | 2 +- 3 files changed, 93 insertions(+), 93 deletions(-) diff --git a/deno.lock b/deno.lock index b55236ca44..38fa6f7fd6 100644 --- a/deno.lock +++ b/deno.lock @@ -114,7 +114,7 @@ "npm:remark-gfm@4.0.1": "4.0.1", "npm:remark-parse@11.0.0": "11.0.0", "npm:remark-rehype@11.1.2": "11.1.2", - "npm:sharp@0.35.3": "0.35.3", + "npm:sharp@0.35.4": "0.35.4", "npm:tailwind-scrollbar-hide@2.0.0": "2.0.0_tailwindcss@4.2.2", "npm:tailwindcss-animate@1.0.7": "1.0.7_tailwindcss@4.2.2", "npm:tailwindcss@4.2.2": "4.2.2", @@ -1002,10 +1002,10 @@ "os": ["darwin"], "cpu": ["arm64"] }, - "@img/sharp-darwin-arm64@0.35.3": { - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "@img/sharp-darwin-arm64@0.35.4": { + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "optionalDependencies": [ - "@img/sharp-libvips-darwin-arm64@1.3.2" + "@img/sharp-libvips-darwin-arm64@1.3.3" ], "os": ["darwin"], "cpu": ["arm64"] @@ -1018,18 +1018,18 @@ "os": ["darwin"], "cpu": ["x64"] }, - "@img/sharp-darwin-x64@0.35.3": { - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "@img/sharp-darwin-x64@0.35.4": { + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "optionalDependencies": [ - "@img/sharp-libvips-darwin-x64@1.3.2" + "@img/sharp-libvips-darwin-x64@1.3.3" ], "os": ["darwin"], "cpu": ["x64"] }, - "@img/sharp-freebsd-wasm32@0.35.3": { - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "@img/sharp-freebsd-wasm32@0.35.4": { + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "dependencies": [ - "@img/sharp-wasm32@0.35.3" + "@img/sharp-wasm32@0.35.4" ], "os": ["freebsd"] }, @@ -1038,8 +1038,8 @@ "os": ["darwin"], "cpu": ["arm64"] }, - "@img/sharp-libvips-darwin-arm64@1.3.2": { - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "@img/sharp-libvips-darwin-arm64@1.3.3": { + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "os": ["darwin"], "cpu": ["arm64"] }, @@ -1048,8 +1048,8 @@ "os": ["darwin"], "cpu": ["x64"] }, - "@img/sharp-libvips-darwin-x64@1.3.2": { - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "@img/sharp-libvips-darwin-x64@1.3.3": { + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "os": ["darwin"], "cpu": ["x64"] }, @@ -1058,8 +1058,8 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-libvips-linux-arm64@1.3.2": { - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "@img/sharp-libvips-linux-arm64@1.3.3": { + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "os": ["linux"], "cpu": ["arm64"] }, @@ -1068,8 +1068,8 @@ "os": ["linux"], "cpu": ["arm"] }, - "@img/sharp-libvips-linux-arm@1.3.2": { - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "@img/sharp-libvips-linux-arm@1.3.3": { + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "os": ["linux"], "cpu": ["arm"] }, @@ -1078,8 +1078,8 @@ "os": ["linux"], "cpu": ["ppc64"] }, - "@img/sharp-libvips-linux-ppc64@1.3.2": { - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "@img/sharp-libvips-linux-ppc64@1.3.3": { + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "os": ["linux"], "cpu": ["ppc64"] }, @@ -1088,8 +1088,8 @@ "os": ["linux"], "cpu": ["riscv64"] }, - "@img/sharp-libvips-linux-riscv64@1.3.2": { - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "@img/sharp-libvips-linux-riscv64@1.3.3": { + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "os": ["linux"], "cpu": ["riscv64"] }, @@ -1098,8 +1098,8 @@ "os": ["linux"], "cpu": ["s390x"] }, - "@img/sharp-libvips-linux-s390x@1.3.2": { - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "@img/sharp-libvips-linux-s390x@1.3.3": { + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "os": ["linux"], "cpu": ["s390x"] }, @@ -1108,8 +1108,8 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-libvips-linux-x64@1.3.2": { - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "@img/sharp-libvips-linux-x64@1.3.3": { + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "os": ["linux"], "cpu": ["x64"] }, @@ -1118,8 +1118,8 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-libvips-linuxmusl-arm64@1.3.2": { - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "@img/sharp-libvips-linuxmusl-arm64@1.3.3": { + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "os": ["linux"], "cpu": ["arm64"] }, @@ -1128,8 +1128,8 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-libvips-linuxmusl-x64@1.3.2": { - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "@img/sharp-libvips-linuxmusl-x64@1.3.3": { + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "os": ["linux"], "cpu": ["x64"] }, @@ -1141,10 +1141,10 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-linux-arm64@0.35.3": { - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "@img/sharp-linux-arm64@0.35.4": { + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-arm64@1.3.2" + "@img/sharp-libvips-linux-arm64@1.3.3" ], "os": ["linux"], "cpu": ["arm64"] @@ -1157,10 +1157,10 @@ "os": ["linux"], "cpu": ["arm"] }, - "@img/sharp-linux-arm@0.35.3": { - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "@img/sharp-linux-arm@0.35.4": { + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "optionalDependencies": [ - "@img/sharp-libvips-linux-arm@1.3.2" + "@img/sharp-libvips-linux-arm@1.3.3" ], "os": ["linux"], "cpu": ["arm"] @@ -1173,10 +1173,10 @@ "os": ["linux"], "cpu": ["ppc64"] }, - "@img/sharp-linux-ppc64@0.35.3": { - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "@img/sharp-linux-ppc64@0.35.4": { + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-ppc64@1.3.2" + "@img/sharp-libvips-linux-ppc64@1.3.3" ], "os": ["linux"], "cpu": ["ppc64"] @@ -1189,10 +1189,10 @@ "os": ["linux"], "cpu": ["riscv64"] }, - "@img/sharp-linux-riscv64@0.35.3": { - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "@img/sharp-linux-riscv64@0.35.4": { + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "optionalDependencies": [ - "@img/sharp-libvips-linux-riscv64@1.3.2" + "@img/sharp-libvips-linux-riscv64@1.3.3" ], "os": ["linux"], "cpu": ["riscv64"] @@ -1205,10 +1205,10 @@ "os": ["linux"], "cpu": ["s390x"] }, - "@img/sharp-linux-s390x@0.35.3": { - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "@img/sharp-linux-s390x@0.35.4": { + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-s390x@1.3.2" + "@img/sharp-libvips-linux-s390x@1.3.3" ], "os": ["linux"], "cpu": ["s390x"] @@ -1221,10 +1221,10 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-linux-x64@0.35.3": { - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "@img/sharp-linux-x64@0.35.4": { + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "optionalDependencies": [ - "@img/sharp-libvips-linux-x64@1.3.2" + "@img/sharp-libvips-linux-x64@1.3.3" ], "os": ["linux"], "cpu": ["x64"] @@ -1237,10 +1237,10 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@img/sharp-linuxmusl-arm64@0.35.3": { - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "@img/sharp-linuxmusl-arm64@0.35.4": { + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "optionalDependencies": [ - "@img/sharp-libvips-linuxmusl-arm64@1.3.2" + "@img/sharp-libvips-linuxmusl-arm64@1.3.3" ], "os": ["linux"], "cpu": ["arm64"] @@ -1253,10 +1253,10 @@ "os": ["linux"], "cpu": ["x64"] }, - "@img/sharp-linuxmusl-x64@0.35.3": { - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "@img/sharp-linuxmusl-x64@0.35.4": { + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "optionalDependencies": [ - "@img/sharp-libvips-linuxmusl-x64@1.3.2" + "@img/sharp-libvips-linuxmusl-x64@1.3.3" ], "os": ["linux"], "cpu": ["x64"] @@ -1268,16 +1268,16 @@ ], "cpu": ["wasm32"] }, - "@img/sharp-wasm32@0.35.3": { - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "@img/sharp-wasm32@0.35.4": { + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dependencies": [ "@emnapi/runtime@1.11.3" ] }, - "@img/sharp-webcontainers-wasm32@0.35.3": { - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "@img/sharp-webcontainers-wasm32@0.35.4": { + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "dependencies": [ - "@img/sharp-wasm32@0.35.3" + "@img/sharp-wasm32@0.35.4" ], "cpu": ["wasm32"] }, @@ -1286,8 +1286,8 @@ "os": ["win32"], "cpu": ["arm64"] }, - "@img/sharp-win32-arm64@0.35.3": { - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "@img/sharp-win32-arm64@0.35.4": { + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "os": ["win32"], "cpu": ["arm64"] }, @@ -1296,8 +1296,8 @@ "os": ["win32"], "cpu": ["ia32"] }, - "@img/sharp-win32-ia32@0.35.3": { - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "@img/sharp-win32-ia32@0.35.4": { + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "os": ["win32"], "cpu": ["ia32"] }, @@ -1306,8 +1306,8 @@ "os": ["win32"], "cpu": ["x64"] }, - "@img/sharp-win32-x64@0.35.3": { - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "@img/sharp-win32-x64@0.35.4": { + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "os": ["win32"], "cpu": ["x64"] }, @@ -5172,39 +5172,39 @@ ], "scripts": true }, - "sharp@0.35.3": { - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "sharp@0.35.4": { + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dependencies": [ "@img/colour", "detect-libc", "semver" ], "optionalDependencies": [ - "@img/sharp-darwin-arm64@0.35.3", - "@img/sharp-darwin-x64@0.35.3", + "@img/sharp-darwin-arm64@0.35.4", + "@img/sharp-darwin-x64@0.35.4", "@img/sharp-freebsd-wasm32", - "@img/sharp-libvips-darwin-arm64@1.3.2", - "@img/sharp-libvips-darwin-x64@1.3.2", - "@img/sharp-libvips-linux-arm@1.3.2", - "@img/sharp-libvips-linux-arm64@1.3.2", - "@img/sharp-libvips-linux-ppc64@1.3.2", - "@img/sharp-libvips-linux-riscv64@1.3.2", - "@img/sharp-libvips-linux-s390x@1.3.2", - "@img/sharp-libvips-linux-x64@1.3.2", - "@img/sharp-libvips-linuxmusl-arm64@1.3.2", - "@img/sharp-libvips-linuxmusl-x64@1.3.2", - "@img/sharp-linux-arm@0.35.3", - "@img/sharp-linux-arm64@0.35.3", - "@img/sharp-linux-ppc64@0.35.3", - "@img/sharp-linux-riscv64@0.35.3", - "@img/sharp-linux-s390x@0.35.3", - "@img/sharp-linux-x64@0.35.3", - "@img/sharp-linuxmusl-arm64@0.35.3", - "@img/sharp-linuxmusl-x64@0.35.3", + "@img/sharp-libvips-darwin-arm64@1.3.3", + "@img/sharp-libvips-darwin-x64@1.3.3", + "@img/sharp-libvips-linux-arm@1.3.3", + "@img/sharp-libvips-linux-arm64@1.3.3", + "@img/sharp-libvips-linux-ppc64@1.3.3", + "@img/sharp-libvips-linux-riscv64@1.3.3", + "@img/sharp-libvips-linux-s390x@1.3.3", + "@img/sharp-libvips-linux-x64@1.3.3", + "@img/sharp-libvips-linuxmusl-arm64@1.3.3", + "@img/sharp-libvips-linuxmusl-x64@1.3.3", + "@img/sharp-linux-arm@0.35.4", + "@img/sharp-linux-arm64@0.35.4", + "@img/sharp-linux-ppc64@0.35.4", + "@img/sharp-linux-riscv64@0.35.4", + "@img/sharp-linux-s390x@0.35.4", + "@img/sharp-linux-x64@0.35.4", + "@img/sharp-linuxmusl-arm64@0.35.4", + "@img/sharp-linuxmusl-x64@0.35.4", "@img/sharp-webcontainers-wasm32", - "@img/sharp-win32-arm64@0.35.3", - "@img/sharp-win32-ia32@0.35.3", - "@img/sharp-win32-x64@0.35.3" + "@img/sharp-win32-arm64@0.35.4", + "@img/sharp-win32-ia32@0.35.4", + "@img/sharp-win32-x64@0.35.4" ] }, "simple-concat@1.0.1": { @@ -6561,7 +6561,7 @@ "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", - "npm:sharp@0.35.3" + "npm:sharp@0.35.4" ] }, "extensions/ext-llm-anthropic": { diff --git a/extensions/ext-image-sharp/deno.json b/extensions/ext-image-sharp/deno.json index 4b1063ccb9..84f83756c8 100644 --- a/extensions/ext-image-sharp/deno.json +++ b/extensions/ext-image-sharp/deno.json @@ -21,7 +21,7 @@ ] }, "imports": { - "sharp": "npm:sharp@0.35.3", + "sharp": "npm:sharp@0.35.4", "@std/assert": "jsr:@std/assert@1.0.19", "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", "veryfront/extensions": "../../src/extensions/types.ts", diff --git a/extensions/ext-image-sharp/src/index.test.ts b/extensions/ext-image-sharp/src/index.test.ts index 5ea744979c..e9b0aa13c2 100644 --- a/extensions/ext-image-sharp/src/index.test.ts +++ b/extensions/ext-image-sharp/src/index.test.ts @@ -54,7 +54,7 @@ describe("ext-image-sharp", () => { it("keeps cache identity immutable and backend-specific", () => { const engine = new SharpImageOptimizationEngine(); - assertStringIncludes(engine.cacheIdentity, "sharp@0.35.3"); + assertStringIncludes(engine.cacheIdentity, "sharp@0.35.4"); assertStringIncludes(engine.cacheIdentity, `vips@${sharp.versions.vips}`); assertEquals(Object.isFrozen(engine), true); assertThrows( From 0e187c43dc8a88c3cb848e3e273c809ced3cc58e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:14:16 +0200 Subject: [PATCH 130/194] fix(agent): protect filtered output and copied message fields --- .../slash-command-artifact-policy.ts | 21 +++--- src/agent/middleware/security/validator.ts | 42 ++++++++---- src/agent/runtime/index.ts | 14 ++++ src/security/private-array.ts | 12 ++++ .../executor-iterator-intrinsics.test.ts | 49 +++++++++++++- .../agent/stream-state-map-intrinsics.test.ts | 64 +++++++++++++++++++ .../turn-validation-set-intrinsics.test.ts | 64 +++++++++++++++++++ 7 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 tests/integration/agent/stream-state-map-intrinsics.test.ts create mode 100644 tests/integration/agent/turn-validation-set-intrinsics.test.ts diff --git a/src/agent/artifacts/slash-command-artifact-policy.ts b/src/agent/artifacts/slash-command-artifact-policy.ts index d682d148bd..55bb057e9d 100644 --- a/src/agent/artifacts/slash-command-artifact-policy.ts +++ b/src/agent/artifacts/slash-command-artifact-policy.ts @@ -1,3 +1,4 @@ +import { somePrivateArray } from "#veryfront/security/private-array.ts"; import { isRecord } from "#veryfront/chat/conversation.ts"; const SLASH_COMMAND_PATTERN = /(?:^|)\s*\/[a-z0-9_-]+/i; @@ -103,12 +104,12 @@ function resolveToolName( } function hasToolCallOrResult(messages: readonly unknown[], toolName: string): boolean { - return messages.some((message) => { + return somePrivateArray(messages, (message) => { if (!isRecord(message) || !Array.isArray(message.content)) { return false; } - return message.content.some((part) => { + return somePrivateArray(message.content, (part) => { if (!isRecord(part) || typeof part.toolName !== "string") { return false; } @@ -120,12 +121,15 @@ function hasToolCallOrResult(messages: readonly unknown[], toolName: string): bo } function containsSlashCommand(messages: readonly unknown[]): boolean { - return messages.some((message) => { + return somePrivateArray(messages, (message) => { if (!isRecord(message) || message.role !== "user") { return false; } - return extractMessageTexts(message.content).some((text) => SLASH_COMMAND_PATTERN.test(text)); + return somePrivateArray( + extractMessageTexts(message.content), + (text) => SLASH_COMMAND_PATTERN.test(text), + ); }); } @@ -147,14 +151,15 @@ function containsExactArtifactPath(messages: readonly unknown[]): boolean { } } - return messages.some((message) => { + return somePrivateArray(messages, (message) => { if (!isRecord(message)) { return false; } if (message.role === "user") { - return extractMessageTexts(message.content).some((text) => - EXACT_ARTIFACT_PATH_PATTERN.test(text) + return somePrivateArray( + extractMessageTexts(message.content), + (text) => EXACT_ARTIFACT_PATH_PATTERN.test(text), ); } @@ -175,7 +180,7 @@ function containsExactArtifactPath(messages: readonly unknown[]): boolean { return false; } - return message.content.some((part) => { + return somePrivateArray(message.content, (part) => { if (!isToolResultPart(part) || !isRecord(part)) { return false; } diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index c57209f601..51df617ab7 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,3 +1,4 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { concatPrivateArrays, @@ -132,8 +133,16 @@ const PII_REPLACEMENTS: Array<{ pattern: RegExp; label: string }> = [ const RegExpConstructor = RegExp; const regexpExec = RegExp.prototype.exec; +const regexpReplace = RegExp.prototype[Symbol.replace]; const applyRegExp = Reflect.apply; +function replacePrivatePattern(input: string, pattern: RegExp, replacement: string): string { + const matcher = new RegExpConstructor(pattern.source, pattern.flags); + matcher.lastIndex = pattern.lastIndex; + defineOwnDataProperty(matcher, "exec", regexpExec); + return applyRegExp(regexpReplace, matcher, [input, replacement]) as string; +} + function testBlockedPattern(pattern: RegExp, input: string): boolean { return applyRegExp(regexpExec, freshStatefulPattern(pattern), [input]) !== null; } @@ -153,7 +162,7 @@ function advanceStringIndex(input: string, index: number, unicode: boolean): num function redactBlockedPattern(input: string, pattern: RegExp): string { const matcher = freshStatefulPattern(pattern); - if (!matcher.sticky) return input.replace(matcher, "[REDACTED]"); + if (!matcher.sticky) return replacePrivatePattern(input, matcher, "[REDACTED]"); // replace() resets global sticky regexes to index 0, and Bun does not // currently honor lastIndex for non-global sticky replacements. Use exec and @@ -163,7 +172,11 @@ function redactBlockedPattern(input: string, pattern: RegExp): string { let cursor = 0; let matched = false; - for (let match = matcher.exec(input); match; match = matcher.exec(input)) { + for ( + let match = applyRegExp(regexpExec, matcher, [input]) as RegExpExecArray | null; + match; + match = applyRegExp(regexpExec, matcher, [input]) as RegExpExecArray | null + ) { redacted += `${input.slice(cursor, match.index)}[REDACTED]`; cursor = match.index + match[0].length; matched = true; @@ -288,10 +301,11 @@ export class InputValidator { * Sanitize input (remove potentially harmful content) */ private sanitizeInput(input: string): string { - return InputValidator.SANITIZE_PATTERNS.reduce( - (text, pattern) => text.replace(pattern, ""), - input, - ); + let sanitized = input; + for (let index = 0; index < InputValidator.SANITIZE_PATTERNS.length; index++) { + sanitized = replacePrivatePattern(sanitized, InputValidator.SANITIZE_PATTERNS[index]!, ""); + } + return sanitized; } } @@ -346,10 +360,12 @@ export class OutputFilter { * Filter PII from output */ private filterPII(output: string): string { - return PII_REPLACEMENTS.reduce( - (text, { pattern, label }) => text.replace(pattern, label), - output, - ); + let filtered = output; + for (let index = 0; index < PII_REPLACEMENTS.length; index++) { + const { pattern, label } = PII_REPLACEMENTS[index]!; + filtered = replacePrivatePattern(filtered, pattern, label); + } + return filtered; } } @@ -1403,7 +1419,11 @@ function patternOccurrences(input: string, pattern: RegExp): { index: number; te const matcher = new RegExp(pattern.source, pattern.global ? pattern.flags : `${pattern.flags}g`); if (pattern.sticky) matcher.lastIndex = pattern.lastIndex; const matches: { index: number; text: string }[] = []; - for (let match = matcher.exec(input); match; match = matcher.exec(input)) { + for ( + let match = applyRegExp(regexpExec, matcher, [input]) as RegExpExecArray | null; + match; + match = applyRegExp(regexpExec, matcher, [input]) as RegExpExecArray | null + ) { matches.push({ index: match.index, text: match[0] }); if (match[0].length === 0) { matcher.lastIndex = advanceStringIndex( diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index ee3d120eec..06be83c866 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -561,12 +561,25 @@ const PROVIDER_VISIBLE_MESSAGE_PART_FIELDS = [ "upload_path", ] as const; +function avoidsAmbientMessagePartField(part: MessagePart, key: string): boolean { + // Keep structural proxy fields and custom prototype fields compatible while + // excluding accessors installed on the shared Object prototype. + if (!ObjectHasOwn(ObjectPrototype, key)) return true; + let current: object | null = part; + while (current !== null && current !== ObjectPrototype) { + if (ObjectHasOwn(current, key)) return true; + current = ObjectGetPrototypeOf(current); + } + return false; +} + function cloneKnownMessagePartFields(part: MessagePart): MessagePart { const detached = ObjectCreate(ObjectPrototype) as Record; const source = part as Record; for (const key of PROVIDER_VISIBLE_MESSAGE_PART_FIELDS) { let value: unknown; try { + if (!avoidsAmbientMessagePartField(part, key)) continue; value = source[key]; } catch { continue; @@ -623,6 +636,7 @@ function cloneMessagePartForCommit(part: MessagePart): MessagePart { if (ObjectHasOwn(descriptors, key)) continue; let value: unknown; try { + if (!avoidsAmbientMessagePartField(part, key)) continue; value = source[key]; } catch { continue; diff --git a/src/security/private-array.ts b/src/security/private-array.ts index e1ba8d2e7d..c5a8ef9036 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -4,6 +4,18 @@ const hasOwn = Object.hasOwn; const isArray = Array.isArray; const apply = Reflect.apply; +/** Test private array elements without exposing the receiver to writable methods. */ +export function somePrivateArray( + values: readonly T[], + predicate: (value: T, index: number, values: readonly T[]) => unknown, +): boolean { + const length = values.length; + for (let index = 0; index < length; index++) { + if (hasOwn(values, index) && predicate(values[index]!, index, values)) return true; + } + return false; +} + /** Filter private arrays through own elements without consulting array species. */ export function filterPrivateArray( values: readonly T[], diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index b4300b7a4a..06af24f3d3 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -21,6 +21,10 @@ describe("prepared executor private iteration", () => { "array flattening", "array joining", "promise chaining", + "regexp testing", + "regexp execution", + "reasoning scans", + "reasoning signatures", ] ) { it(`keeps stream requests and model output out of replaced ${probe}`, async () => { @@ -37,7 +41,15 @@ describe("prepared executor private iteration", () => { let facadeCleanups = 0; let discoveryCleanups = 0; const marker = "synthetic-private-iterator-marker"; - const model = scriptedModel([{ text: marker }]); + const model = scriptedModel([{ + parts: [ + { type: "reasoning-start", id: "synthetic-reasoning" }, + { type: "reasoning-delta", id: "synthetic-reasoning", delta: marker }, + { type: "reasoning-end", id: "synthetic-reasoning" }, + { type: "text-delta", text: marker }, + { type: "finish", finishReason: "stop" }, + ], + }]); const discovery = createExecutorDiscovery({ binding, source, @@ -108,6 +120,10 @@ describe("prepared executor private iteration", () => { const originalFlatMap = Array.prototype.flatMap; const originalJoin = Array.prototype.join; const originalThen = Promise.prototype.then; + const originalTest = RegExp.prototype.test; + const originalExec = RegExp.prototype.exec; + const originalSome = Array.prototype.some; + const originalSignature = Object.getOwnPropertyDescriptor(Object.prototype, "signature"); const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); const originalNext = prototype.next; const originalReturn = prototype.return; @@ -153,7 +169,30 @@ describe("prepared executor private iteration", () => { }; try { await Promise.all([broker.ready, executor.ready]); - if (probe === "async generators") { + if (probe === "regexp testing") { + RegExp.prototype.test = function (input) { + if (input.includes(marker)) observations++; + return Reflect.apply(originalTest, this, [input]); + }; + } else if (probe === "regexp execution") { + RegExp.prototype.exec = function (input) { + if (input.includes(marker)) observations++; + return Reflect.apply(originalExec, this, [input]); + }; + } else if (probe === "reasoning scans") { + Array.prototype.some = (function (this: unknown[], ...args: unknown[]) { + observeTextArray(this); + return Reflect.apply(originalSome, this, args); + }) as typeof originalSome; + } else if (probe === "reasoning signatures") { + Object.defineProperty(Object.prototype, "signature", { + configurable: true, + get() { + if (Object.getOwnPropertyDescriptor(this, "text")?.value === marker) observations++; + return undefined; + }, + }); + } else if (probe === "async generators") { prototype.next = hook(originalNext); prototype.return = hook(originalReturn); } else if (probe === "array iteration") { @@ -245,6 +284,12 @@ describe("prepared executor private iteration", () => { Array.prototype.flatMap = originalFlatMap; Array.prototype.join = originalJoin; Promise.prototype.then = originalThen; + RegExp.prototype.test = originalTest; + RegExp.prototype.exec = originalExec; + Array.prototype.some = originalSome; + if (originalSignature) { + Object.defineProperty(Object.prototype, "signature", originalSignature); + } else Reflect.deleteProperty(Object.prototype, "signature"); if (originalMetadata) Object.defineProperty(Object.prototype, "metadata", originalMetadata); else Reflect.deleteProperty(Object.prototype, "metadata"); prototype.next = originalNext; diff --git a/tests/integration/agent/stream-state-map-intrinsics.test.ts b/tests/integration/agent/stream-state-map-intrinsics.test.ts new file mode 100644 index 0000000000..b60852d8a7 --- /dev/null +++ b/tests/integration/agent/stream-state-map-intrinsics.test.ts @@ -0,0 +1,64 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createMockResult, + createSSECollector, +} from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; +import { createStreamState, processStream } from "#veryfront/agent/runtime/chat-stream-handler.ts"; + +describe("private stream maps", () => { + for (const probe of ["construction", "operations"]) { + it(`keeps accumulated model arguments out of replaced Map ${probe}`, async () => { + const marker = "synthetic-private-tool-arguments"; + const { controller, encoder } = createSSECollector(); + const result = createMockResult([ + { type: "tool-input-start", id: "synthetic-call", toolName: "inspect" }, + { type: "tool-input-delta", id: "synthetic-call", delta: JSON.stringify({ text: marker }) }, + { type: "tool-input-end", id: "synthetic-call" }, + { type: "finish", finishReason: "tool-calls", totalUsage: null }, + ]); + const NativeMap = Map; + const nativeSet = Map.prototype.set; + const nativeGet = Map.prototype.get; + const retained: Map[] = []; + let state: ReturnType; + try { + if (probe === "construction") { + globalThis.Map = class extends NativeMap { + constructor(entries?: Iterable | null) { + super(entries); + retained.push(this); + } + }; + } else { + Map.prototype.set = function (key, value) { + retained.push(this); + return Reflect.apply(nativeSet, this, [key, value]); + }; + Map.prototype.get = function (key) { + retained.push(this); + return Reflect.apply(nativeGet, this, [key]); + }; + } + state = createStreamState(); + await processStream(result, state, controller, encoder, "synthetic-text", undefined); + } finally { + globalThis.Map = NativeMap; + Map.prototype.set = nativeSet; + Map.prototype.get = nativeGet; + } + assertEquals( + state.toolCalls.get("synthetic-call")?.arguments, + JSON.stringify({ text: marker }), + ); + let exposures = 0; + for (const map of retained) { + for (const value of map.values()) { + if (JSON.stringify(value)?.includes(marker)) exposures++; + } + } + assertEquals(exposures, 0); + }); + } +}); diff --git a/tests/integration/agent/turn-validation-set-intrinsics.test.ts b/tests/integration/agent/turn-validation-set-intrinsics.test.ts new file mode 100644 index 0000000000..6ddd770386 --- /dev/null +++ b/tests/integration/agent/turn-validation-set-intrinsics.test.ts @@ -0,0 +1,64 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { AgentContext, Message } from "#veryfront/agent/types.ts"; +import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; +import { getTurnMessageValidator } from "#veryfront/agent/middleware/turn-validation.ts"; + +describe("private turn membership", () => { + for (const probe of ["Set constructor", "array iterator"]) { + it(`keeps current messages out of the replaced ${probe} when history exists`, async () => { + const turnInput: Message[] = [{ + id: "current", + role: "user", + parts: [{ type: "text", text: "Synthetic current input" }], + }]; + const history: Message[] = [{ + id: "earlier", + role: "user", + parts: [{ type: "text", text: "Synthetic earlier input" }], + }]; + const context: AgentContext = { + agentId: "synthetic", + input: turnInput, + model: "hosted/synthetic", + data: {}, + platform: {}, + }; + await securityMiddleware({ input: { blockedPatterns: [/synthetic-never-matches/] } })( + context, + () => + Promise.resolve({ + text: "ok", + messages: [], + toolCalls: [], + status: "completed", + }), + ); + const validate = getTurnMessageValidator(context)!; + const NativeSet = Set; + const iterator = Array.prototype[Symbol.iterator]; + let exposures = 0; + try { + if (probe === "Set constructor") { + globalThis.Set = class extends NativeSet { + constructor(values?: Iterable | null) { + if (values === turnInput) exposures++; + super(values); + } + }; + } else { + Array.prototype[Symbol.iterator] = function () { + if (this === turnInput) exposures++; + return Reflect.apply(iterator, this, []); + }; + } + await validate(history, turnInput); + } finally { + globalThis.Set = NativeSet; + Array.prototype[Symbol.iterator] = iterator; + } + assertEquals(exposures, 0); + }); + } +}); From d25bcbbb0852a99a64d2ef76529787c331207cd4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:15:39 +0200 Subject: [PATCH 131/194] fix(build): synchronize proxy lockfile with sharp patch --- scripts/build/proxy-deno.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock index 076fefdada..9a6db630ed 100644 --- a/scripts/build/proxy-deno.lock +++ b/scripts/build/proxy-deno.lock @@ -1652,7 +1652,7 @@ "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", - "npm:sharp@0.35.3" + "npm:sharp@0.35.4" ] }, "extensions/ext-llm-anthropic": { From fdcbf83e39ee72c0b551322c5e02c726314ae0c1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:15:39 +0200 Subject: [PATCH 132/194] fix(build): synchronize proxy lockfile with sharp patch --- scripts/build/proxy-deno.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock index 076fefdada..9a6db630ed 100644 --- a/scripts/build/proxy-deno.lock +++ b/scripts/build/proxy-deno.lock @@ -1652,7 +1652,7 @@ "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", - "npm:sharp@0.35.3" + "npm:sharp@0.35.4" ] }, "extensions/ext-llm-anthropic": { From 67a6087b150052d12f438e613faa3899d5fc6793 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:24:04 +0200 Subject: [PATCH 133/194] fix(agent): validate broker authority against installed grants --- docs/api-reference/veryfront/agent.md | 38 ++++----- docs/guides/agent-service-runtime.md | 4 + .../hosted/managed-executor-broker.test.ts | 77 +++++++++++++++++++ src/agent/hosted/managed-executor-broker.ts | 33 +++++++- 4 files changed, 132 insertions(+), 20 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index f634a1bdfe..fe1ab2ed27 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -2016,22 +2016,22 @@ import { #### Types -| Name | Description | Source | -| ------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | -| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | -| `ManagedAgentBrokerIngressAuthority` | Private parsed request and credentials available only to trusted broker preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedAgentExecutorRequest` | Detached bounded application data, without HTTP objects or broker credentials. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedAgentIngressResult` | Preserve the distinct durable and direct AG-UI ingress contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | -| `ManagedBrokerOutput` | Acknowledging output writes and terminal finalization for a canonical run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | -| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | -| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | -| `ManagedNodeBrokerHandler` | Trusted broker route handler and optional retirement hook. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | -| `ManagedNodeBrokerPool` | Broker admission and settlement lifecycle retained by the HTTP server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | +| Name | Description | Source | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `BrokerIngressErrorCode` | Fixed, credential-free ingress failure identifiers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerIngressScopeInput` | Signed identity and credentials supplied to the trusted scope verifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentExecutorInput` | Validated application data that can cross the executor channel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngress` | Separate private authority and executor-safe invocation data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentIngressOptions` | Broker-owned verification policy for one expected run and source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `BrokerRuntimeAgentPrivateAuthority` | HTTP credentials and verified authority retained exclusively in the broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/broker-ingress.ts) | +| `ConnectExecutorTransportOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-node-transport.ts) | +| `ManagedAgentBrokerIngressAuthority` | Private parsed request and credentials available only to trusted broker preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedAgentExecutorRequest` | Detached bounded application data, without HTTP objects or broker credentials. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedAgentIngressResult` | Preserve the distinct durable and direct AG-UI ingress contracts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-hosted-ingress.ts) | +| `ManagedBrokerOutput` | Acknowledging output writes and terminal finalization for a canonical run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-broker-persistence.ts) | +| `ManagedExecutorBrokerOptions` | Process admission and shutdown limits for a managed broker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorRuntime` | Prepared executor handle with broker-owned execution and retirement. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedExecutorStarter` | Trusted executor admission boundary with actual settlement notification. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-broker-handler.ts) | +| `ManagedExecutorStartInput` | Trusted per-invocation source, model, tool, persistence, and state authority. Broker model limits, provider tools, and tool capabilities must not exceed the corresponding installed grant. Startup rejects mismatches before allocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/managed-executor-broker.ts) | +| `ManagedNodeBrokerHandler` | Trusted broker route handler and optional retirement hook. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | +| `ManagedNodeBrokerPool` | Broker admission and settlement lifecycle retained by the HTTP server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/managed-node-broker.ts) | diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 740052e574..343f8bb884 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -484,3 +484,7 @@ A working service streams AG-UI events back. If Veryfront Cloud registration is enabled, the service should also appear in the cloud dashboard's agent service list after the first heartbeat (`VERYFRONT_AGENT_SERVICE_HEARTBEAT_INTERVAL_MS`). + +Broker model output limits and provider-tool descriptors must stay within the installed model grant. +Each broker tool capability must also stay within the installed tool allowlist. Startup rejects broader +broker authority before allocating an executor. Narrower broker limits remain valid. diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index 3ef90695e1..f8ae274a5c 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -317,6 +317,83 @@ function configureCanonical( } describe("managed executor broker", () => { + for (const mismatch of ["output tokens", "provider tools", "tool allowlist"]) { + it(`rejects a broker ${mismatch} grant broader than its installation before allocation`, async () => { + const f = fixture(); + if (mismatch === "output tokens") { + f.input.model.grant.models.get(modelId)!.maxOutputTokens = 101; + } else if (mismatch === "provider tools") { + f.input.model.grant.models.get(modelId)!.providerTools = [{ + type: "provider", + name: "web_search", + id: "openai.web_search", + args: {}, + }]; + } else { + f.input.tools.sources = new Map([["synthetic", { + source: { + id: "synthetic", + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ result: "unexpected" }), + }, + allowedToolNames: new Set(["ungranted"]), + context: {}, + }]]); + } + const broker = createManagedExecutorBroker({ maxActive: 1 }); + let runtime: Awaited> | undefined; + try { + await assertRejects( + async () => { + runtime = await broker.start(f.input); + }, + TypeError, + "exceeds the installed", + ); + assertEquals(f.calls, []); + assertEquals(broker.active, 0); + } finally { + await runtime?.close(); + await broker.shutdown(); + await broker.settled; + } + }); + } + + it("accepts narrower model limits and matching provider and tool grants", async () => { + const f = fixture(); + f.input.installation.grant.models[0]!.maxOutputTokens = 200; + f.input.installation.grant.models[0]!.providerToolNames = ["web_search"]; + f.input.model.grant.models.get(modelId)!.providerTools = [{ + type: "provider", + name: "web_search", + id: "openai.web_search", + args: {}, + }]; + f.input.installation.grant.allowedToolNames = ["inspect"]; + f.input.tools.sources = new Map([["synthetic", { + source: { + id: "synthetic", + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ result: "done" }), + }, + allowedToolNames: new Set(["inspect"]), + context: {}, + }]]); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + let runtime: Awaited> | undefined; + try { + runtime = await broker.start(f.input); + runtime.accept({ kind: "execution" }); + await runtime.agent.stream({ messages: [], abortSignal: new AbortController().signal }); + assertEquals(f.executionAllowed, true); + } finally { + await runtime?.close(); + await broker.shutdown(); + await broker.settled; + } + }); + it("installs, discovers, prepares, accepts, and begins execution in exact order", async () => { const f = fixture({ initialCheckpoint: true }); const broker = createManagedExecutorBroker({ maxActive: 1 }); diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index cfc6903a6b..b1d8d70628 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -75,7 +75,11 @@ export interface ManagedExecutorRuntime { close(reason?: "completed" | "canceled"): Promise; } -/** Trusted per-invocation source, model, tool, persistence, and state authority. */ +/** + * Trusted per-invocation source, model, tool, persistence, and state authority. + * Broker model limits, provider tools, and tool capabilities must not exceed + * the corresponding installed grant. Startup rejects mismatches before allocation. + */ export interface ManagedExecutorStartInput { /** Bind canonical persistence to the admitted session before readiness work starts. */ bindSessionOwnedWork?: (owner: HostedExecutorOwnedWork) => void; @@ -138,6 +142,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption ) throw new TypeError("Managed executor installation does not match its session"); const allowedModelIds = new Set(installation.grant.models.map((model) => model.id)); const operationInput = snapshotOperationInput(input); + assertInstalledOperationGrants(operationInput, installation); const bindSessionOwnedWork = input.bindSessionOwnedWork; if ( installation.grant.execution.kind === "ephemeral" && @@ -285,6 +290,32 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption }; } +function assertInstalledOperationGrants( + input: ManagedExecutorOperationInput, + installation: ExecutorRuntimeInstall, +): void { + for (const installed of installation.grant.models) { + const policy = input.model.grant.models.get(installed.id); + // The model broker validates missing policies, IDs, and malformed limits. + if (!policy) continue; + if (policy.maxOutputTokens > installed.maxOutputTokens) { + throw new TypeError("Broker model output allowance exceeds the installed model grant"); + } + const allowedProviderTools = new Set(installed.providerToolNames); + if (policy.providerTools.some((tool) => !allowedProviderTools.has(tool.name))) { + throw new TypeError("Broker provider tool policy exceeds the installed model grant"); + } + } + const allowedTools = new Set(installation.grant.allowedToolNames); + for (const capability of input.tools.sources.values()) { + for (const name of capability.allowedToolNames) { + if (!allowedTools.has(name)) { + throw new TypeError("Broker tool capability exceeds the installed tool grant"); + } + } + } +} + function buildBrokerOperations( binding: ExecutorBinding, signal: AbortSignal, From 39eda0a78e5d37c7d53a1149fed1db13f040370d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:24:20 +0200 Subject: [PATCH 134/194] fix(agent): protect attachment and stream collection boundaries --- src/agent/runtime/index.ts | 5 +- ...xt-generation-runtime-message-converter.ts | 28 ++++-- src/chat/types.test.ts | 19 ++++ src/chat/types.ts | 52 ++++++---- src/security/private-text.ts | 10 ++ .../executor-attachment-intrinsics.test.ts | 98 +++++++++++++++++++ ...or-tool-materialization-intrinsics.test.ts | 60 ++++++++++++ 7 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 tests/integration/agent/executor-attachment-intrinsics.test.ts create mode 100644 tests/integration/agent/executor-tool-materialization-intrinsics.test.ts diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 06be83c866..ffd907b88a 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -3793,7 +3793,10 @@ export class AgentRuntime { } finalFinishReason = state.finishReason ?? finalFinishReason; - const streamedToolCalls = Array.from(state.toolCalls.values()); + const streamedToolCalls: StreamingToolCall[] = []; + for (const toolCall of state.toolCalls.values()) { + pushPrivateArray(streamedToolCalls, toolCall); + } const finalToolResults = collectFinalStreamToolResults(state); // Recovery replays the whole step, so it also re-emits this step's // reasoning — duplicating it in the live stream and in history, with a diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index abc5ee4104..d97f19e385 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -1,4 +1,9 @@ -import { privateTextIncludes, privateTextStartsWith } from "#veryfront/security/private-text.ts"; +import { + privateTextEndsWith, + privateTextIncludes, + privateTextStartsWith, + privateTextTrimStart, +} from "#veryfront/security/private-text.ts"; import { appendPrivateArray, flatMapPrivateArray, @@ -214,6 +219,7 @@ export function buildAttachmentContextFromParts(parts: Message["parts"]): string const url = getStringPartField(part, "url"); return [{ + __proto__: null, name: getStringPartField(part, "filename") ?? (type === "image" ? "image" : "file"), mediaType, ...(uploadId ? { uploadId } : {}), @@ -228,7 +234,7 @@ export function buildAttachmentContextFromParts(parts: Message["parts"]): string } function appendReadableAttachmentContext(text: string, attachmentContext: string): string { - const normalizedContext = attachmentContext.trimStart(); + const normalizedContext = privateTextTrimStart(attachmentContext); if (!normalizedContext) { return text; } @@ -237,7 +243,11 @@ function appendReadableAttachmentContext(text: string, attachmentContext: string return normalizedContext; } - const separator = text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n"; + const separator = privateTextEndsWith(text, "\n\n") + ? "" + : privateTextEndsWith(text, "\n") + ? "\n" + : "\n\n"; return `${text}${separator}${normalizedContext}`; } @@ -343,7 +353,7 @@ export function convertToTextGenerationRuntimeMessage( if (text.length > 0) pushPrivateArray(content, { type: "text", text }); appendPrivateArray(content, fileParts); if (attachmentContext.length > 0) { - pushPrivateArray(content, { type: "text", text: attachmentContext.trimStart() }); + pushPrivateArray(content, { type: "text", text: privateTextTrimStart(attachmentContext) }); } return { role: "user", content }; } @@ -832,11 +842,11 @@ export function convertToTextGenerationRuntimeRequestMessages( // Only a delivered replay checkpoint may keep a trailing assistant message: // live in-run metadata also reaches converted messages, and providers reject // or misread an unexpected trailing prefill on ordinary resumes. - while ( - requestMessages.at(-1)?.role === "assistant" && - !isProviderReplayDelivered(requestMessages.at(-1)) - ) { - requestMessages.pop(); + while (requestMessages.length > 0) { + const lastIndex = requestMessages.length - 1; + const tail = hasOwn(requestMessages, lastIndex) ? requestMessages[lastIndex] : undefined; + if (tail?.role !== "assistant" || isProviderReplayDelivered(tail)) break; + requestMessages.length = lastIndex; } return requestMessages; diff --git a/src/chat/types.test.ts b/src/chat/types.test.ts index 46bde62c61..c7b882fda8 100644 --- a/src/chat/types.test.ts +++ b/src/chat/types.test.ts @@ -16,6 +16,25 @@ const chatUiMessageSchema = getChatUiMessageSchema(); const messageMetadataSchema = getMessageMetadataSchema(); describe("chat/types", () => { + it("builds escaped attachment annotations without consulting an array map override", () => { + const refs = [{ + name: 'report"<&>.pdf', + mediaType: "application/pdf", + url: "https://example.test/a?x=1&y=2", + }]; + let observations = 0; + Object.defineProperty(refs, "map", { + get() { + observations++; + return Array.prototype.map; + }, + }); + assertEquals( + buildDataFileAnnotation(refs), + '\n\n\n\n', + ); + assertEquals(observations, 0); + }); it("exports hosted chat schema factories through veryfront/chat/types", () => { assertEquals( chatRequestContextSchema.parse({ diff --git a/src/chat/types.ts b/src/chat/types.ts index 492a6ef133..bc48e27d33 100644 --- a/src/chat/types.ts +++ b/src/chat/types.ts @@ -1,3 +1,4 @@ +import { joinPrivateArray, mapPrivateArray } from "#veryfront/security/private-array.ts"; import type { ChatMessageMetadata, ChatMessageMetadataUsage, @@ -647,30 +648,45 @@ export function normalizeInlineAttachmentMediaType( } function escapeXmlAttr(value: string): string { - return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, - ">", - ); + let escaped = ""; + for (let index = 0; index < value.length; index++) { + const character = value[index]!; + switch (character) { + case "&": + escaped += "&"; + break; + case '"': + escaped += """; + break; + case "<": + escaped += "<"; + break; + case ">": + escaped += ">"; + break; + default: + escaped += character; + } + } + return escaped; } /** Builds data file annotation. */ export function buildDataFileAnnotation(refs: UploadedFileReference[]): string { if (refs.length === 0) return ""; - const fileTags = refs - .map((ref) => { - const attrs = [`name="${escapeXmlAttr(ref.name)}"`]; - - if (ref.uploadId) attrs.push(`upload_id="${escapeXmlAttr(ref.uploadId)}"`); - if (ref.path) attrs.push(`path="${escapeXmlAttr(ref.path)}"`); - if (typeof ref.size === "number") attrs.push(`size="${ref.size}"`); - if (ref.url) attrs.push(`url="${escapeXmlAttr(ref.url)}"`); - - attrs.push(`type="${escapeXmlAttr(ref.mediaType)}"`); - - return ``; - }) - .join("\n"); + const fileTags = joinPrivateArray( + mapPrivateArray(refs, (ref) => { + let attrs = `name="${escapeXmlAttr(ref.name)}"`; + if (ref.uploadId) attrs += ` upload_id="${escapeXmlAttr(ref.uploadId)}"`; + if (ref.path) attrs += ` path="${escapeXmlAttr(ref.path)}"`; + if (typeof ref.size === "number") attrs += ` size="${ref.size}"`; + if (ref.url) attrs += ` url="${escapeXmlAttr(ref.url)}"`; + attrs += ` type="${escapeXmlAttr(ref.mediaType)}"`; + return ``; + }), + "\n", + ); return `\n\n\n${fileTags}\n`; } diff --git a/src/security/private-text.ts b/src/security/private-text.ts index 67948c6419..2d10a7d3cb 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -2,6 +2,16 @@ const apply = Reflect.apply; const startsWith = String.prototype.startsWith; const slice = String.prototype.slice; const includes = String.prototype.includes; +const endsWith = String.prototype.endsWith; +const trimStart = String.prototype.trimStart; + +export function privateTextEndsWith(value: string, search: string): boolean { + return apply(endsWith, value, [search]) as boolean; +} + +export function privateTextTrimStart(value: string): string { + return apply(trimStart, value, []) as string; +} export function privateTextIncludes(value: string, search: string): boolean { return apply(includes, value, [search]) as boolean; diff --git a/tests/integration/agent/executor-attachment-intrinsics.test.ts b/tests/integration/agent/executor-attachment-intrinsics.test.ts new file mode 100644 index 0000000000..8973eebed3 --- /dev/null +++ b/tests/integration/agent/executor-attachment-intrinsics.test.ts @@ -0,0 +1,98 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { Message } from "#veryfront/agent/types.ts"; +import { convertToTextGenerationRuntimeRequestMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; + +for (const replaceMethods of [false, true]) { + describe(`private attachment conversion ${replaceMethods ? "hooks" : "baseline"}`, () => { + it("escapes annotations and trims assistant tails without exposing private contents", () => { + const marker = "synthetic-private-attachment"; + const messages: Message[] = [ + { + id: "user", + role: "user", + parts: [ + { type: "text", text: marker + " request" }, + { + type: "file", + filename: marker + '"<&>.txt', + mediaType: "text/plain", + url: "https://example.com/" + marker, + uploadId: "attachment", + uploadPath: "uploads/" + marker, + }, + ], + }, + { id: "assistant", role: "assistant", parts: [{ type: "text", text: marker + " tail" }] }, + ]; + const map = Array.prototype.map; + const at = Array.prototype.at; + const pop = Array.prototype.pop; + const replace = String.prototype.replace; + const trimStart = String.prototype.trimStart; + const endsWith = String.prototype.endsWith; + const includes = String.prototype.includes; + const stringify = JSON.stringify; + const apply = Reflect.apply; + let observations = 0; + const observe = (value: unknown) => { + const serialized = typeof value === "string" ? value : stringify(value) ?? ""; + if (apply(includes, serialized, [marker])) observations++; + }; + let converted: ReturnType = []; + try { + if (replaceMethods) { + Array.prototype.map = function (...args) { + observe(this); + return apply(map, this, args); + }; + Array.prototype.at = function (...args) { + observe(this); + return apply(at, this, args); + }; + Array.prototype.pop = function () { + observe(this); + return apply(pop, this, []); + }; + String.prototype.replace = function (...args) { + observe(this); + return apply(replace, this, args); + }; + String.prototype.trimStart = function () { + observe(this); + return apply(trimStart, this, []); + }; + String.prototype.endsWith = function (...args) { + observe(this); + return apply(endsWith, this, args); + }; + } + converted = convertToTextGenerationRuntimeRequestMessages(messages); + } finally { + if (replaceMethods) { + Array.prototype.map = map; + Array.prototype.at = at; + Array.prototype.pop = pop; + String.prototype.replace = replace; + String.prototype.trimStart = trimStart; + String.prototype.endsWith = endsWith; + } + } + assertEquals(converted.length, 1); + assertEquals(converted[0]?.role, "user"); + const content = converted[0]?.content; + if (!Array.isArray(content)) throw new Error("Expected native file content"); + assertEquals(content[0], { type: "text", text: marker + " request" }); + const annotation = content[2]; + if (annotation?.type !== "text") throw new Error("Expected file annotation"); + assertStringIncludes( + annotation.text, + 'name="synthetic-private-attachment"<&>.txt"', + ); + assertEquals(annotation.text.startsWith(""), true); + assertEquals(messages.length, 2); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/executor-tool-materialization-intrinsics.test.ts b/tests/integration/agent/executor-tool-materialization-intrinsics.test.ts new file mode 100644 index 0000000000..221ca02ce3 --- /dev/null +++ b/tests/integration/agent/executor-tool-materialization-intrinsics.test.ts @@ -0,0 +1,60 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { tool } from "#veryfront/tool"; + +for (const replaceMethod of [false, true]) { + describe(`private tool materialization ${replaceMethod ? "hooks" : "baseline"}`, () => { + it("executes model tool arguments without consulting Array.from", async () => { + const marker = "synthetic-private-materialized-arguments"; + const model = scriptedModel([ + { toolCalls: [{ id: "call", name: "inspect", input: { query: marker } }] }, + { text: "Complete" }, + ], { only: "stream" }); + const received: string[] = []; + const runtime = createEphemeralAgentWithRuntimeOptions({ + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic tool instructions", + maxSteps: 2, + tools: { + inspect: tool({ + id: "inspect", + description: "Synthetic inspection", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: (input) => { + received.push(input.query); + return Promise.resolve({ ok: true }); + }, + }), + }, + }, { resolveModelRuntime: () => model }); + const from = Array.from; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + try { + if (replaceMethod) { + Array.from = function ( + items: Iterable | ArrayLike, + ...options: unknown[] + ) { + const result = apply(from, this, [items, ...options]); + if (apply(includes, stringify(result) ?? "", [marker])) observations++; + return result; + }; + } + const result = await runtime.stream({ input: "Synthetic request" }); + await result.toDataStreamResponse().text(); + } finally { + if (replaceMethod) Array.from = from; + } + assertEquals(received, [marker]); + assertEquals(model.callCount, 2); + assertEquals(observations, 0); + }); + }); +} From c6bc7a47bb294f8696c13ffc5b5fcb048ee17dd5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:32:46 +0200 Subject: [PATCH 135/194] fix(agent): close remaining provider collection lookups --- src/agent/runtime/index.ts | 9 ++++---- ...xt-generation-runtime-message-converter.ts | 5 ++++- src/agent/runtime/tool-result-continuation.ts | 21 ++++++++++++------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index ffd907b88a..5145416fde 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -4,6 +4,7 @@ import { filterPrivateArray, mapPrivateArray, pushPrivateArray, + somePrivateArray, } from "#veryfront/security/private-array.ts"; import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; import { @@ -3818,12 +3819,12 @@ export class AgentRuntime { }); const shouldRecoverInterruptedLocalToolBatch = canRecoverInterruptedLocalToolBatch && shouldContinue && - streamedToolCalls.some(isInterruptedClientToolCall); + somePrivateArray(streamedToolCalls, isInterruptedClientToolCall); const exhaustedStepBudgetDuringInterruptedLocalToolRecovery = !recoveredInterruptedLocalToolBatch && step + 1 >= maxSteps && !hasExposedReasoning && - streamedToolCalls.some(isInterruptedClientToolCall) && + somePrivateArray(streamedToolCalls, isInterruptedClientToolCall) && shouldContinueAfterStreamStep(state, { recoverInterruptedToolCalls: true }); // Exactly `shouldRecoverInterruptedLocalToolBatch` with the reasoning // gate lifted: the batch this step would have replayed had it not @@ -3834,12 +3835,12 @@ export class AgentRuntime { const declinedRecoveryForExposedReasoning = hasExposedReasoning && !recoveredInterruptedLocalToolBatch && step + 1 < maxSteps && - streamedToolCalls.some(isInterruptedClientToolCall) && + somePrivateArray(streamedToolCalls, isInterruptedClientToolCall) && shouldContinueAfterStreamStep(state, { recoverInterruptedToolCalls: true }); if (declinedRecoveryForExposedReasoning) { logger.warn("Declined interrupted local tool batch recovery after exposed reasoning", { step, - toolName: streamedToolCalls.find(isInterruptedClientToolCall)?.name, + toolName: filterPrivateArray(streamedToolCalls, isInterruptedClientToolCall)[0]?.name, reasoningPartCount: persistedReasoningParts.length, }); } diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index d97f19e385..6dd51a0b29 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -811,7 +811,10 @@ export function convertToTextGenerationRuntimeMessages( continue; } - const previousMessage = textGenerationRuntimeMessages.at(-1); + const previousIndex = textGenerationRuntimeMessages.length - 1; + const previousMessage = hasOwn(textGenerationRuntimeMessages, previousIndex) + ? textGenerationRuntimeMessages[previousIndex] + : undefined; if (previousMessage?.role === "tool" && convertedMessage.role === "tool") { appendPrivateArray(previousMessage.content, convertedMessage.content); diff --git a/src/agent/runtime/tool-result-continuation.ts b/src/agent/runtime/tool-result-continuation.ts index d01900bd77..1c249f3df5 100644 --- a/src/agent/runtime/tool-result-continuation.ts +++ b/src/agent/runtime/tool-result-continuation.ts @@ -1,3 +1,4 @@ +import { pushPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; import { type Message, type MessagePart, type ToolResultPart } from "../types.ts"; import { stripLeadingEmptyObjectPlaceholder } from "../streaming/data-stream.ts"; import type { @@ -151,16 +152,22 @@ export function shouldContinueAfterStreamStep( return state.finishReason === "tool-calls" && Boolean(state.suppressedToolCalls?.length); } - const streamedToolCalls = Array.from(state.toolCalls.values()); - const hasIncompleteToolCall = streamedToolCalls.some(isStreamedToolCallIncomplete); - const hasFinalizedClientToolCall = streamedToolCalls.some((toolCall) => - toolCall.inputAvailable === true && toolCall.providerExecuted !== true + const streamedToolCalls: StreamingToolCall[] = []; + for (const toolCall of state.toolCalls.values()) pushPrivateArray(streamedToolCalls, toolCall); + const hasIncompleteToolCall = somePrivateArray(streamedToolCalls, isStreamedToolCallIncomplete); + const hasFinalizedClientToolCall = somePrivateArray( + streamedToolCalls, + (toolCall) => toolCall.inputAvailable === true && toolCall.providerExecuted !== true, ); - const hasProviderExecutedToolCall = streamedToolCalls.some((toolCall) => - toolCall.providerExecuted === true + const hasProviderExecutedToolCall = somePrivateArray( + streamedToolCalls, + (toolCall) => toolCall.providerExecuted === true, ); const finalToolResults = collectFinalStreamToolResults(state); - const hasInterruptedClientToolCall = streamedToolCalls.some(isInterruptedClientToolCall); + const hasInterruptedClientToolCall = somePrivateArray( + streamedToolCalls, + isInterruptedClientToolCall, + ); // A finalized local call has already emitted tool-input-available. Client // callbacks may have applied its side effect, so reconstructing the batch // could repeat that mutation even when no result reached this stream. From 12264ac158ff03dacbbfe9f6beb262c74a193cab Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 10:46:32 +0200 Subject: [PATCH 136/194] fix(agent): protect conversation loading and implicit skill selection --- .../hosted/chat-runtime-tool-assembly.test.ts | 27 +++++++++++++++++++ .../hosted/chat-runtime-tool-assembly.ts | 5 +++- .../hosted/executor-runtime-prepare.test.ts | 24 +++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 4 +-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index 31afcfbed9..6694a11f94 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -24,6 +24,33 @@ import { } from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; describe("private host tool metadata", () => { + it("observes private conversation work without invoking its own then override", async () => { + const text = Promise.resolve("Synthetic private conversation text"); + const nativeThen = Promise.prototype.then; + let observations = 0; + Object.defineProperties(text, { + constructor: { value: Object }, + then: { + value(...args: unknown[]) { + observations++; + return Reflect.apply(nativeThen, text, args); + }, + }, + }); + const assembly = await prepareFacadedHostedChatRuntimeToolAssembly({ + signal: new AbortController().signal, + taskContext: { agentId: "synthetic", model: "veryfront-cloud/openai/gpt-5.4" }, + instructions: "Synthetic instructions", + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + localTools: {}, + allowedToolNames: [], + remoteToolSources: [], + loadLatestConversationUserText: () => text, + }); + assertEquals(assembly.availableToolNames, []); + assertEquals(observations, 0); + }); + it("does not read inherited short names while enforcing denials", async () => { let reads = 0; const definition = Object.create({ diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index 5e1eb7c00e..97373a173c 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -1,3 +1,4 @@ +import { observePrivatePromise } from "#veryfront/security/private-promise.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; @@ -705,7 +706,9 @@ async function prepareHostedChatRuntimeToolAssemblyInternal< if (input.loadLatestConversationUserText) { preparedInstructions = updateDefaultResearchArtifacts({ taskContext: input.taskContext, - latestUserText: await input.loadLatestConversationUserText(input.signal), + latestUserText: await observePrivatePromise( + input.loadLatestConversationUserText(input.signal), + ), system: preparedInstructions, }); } diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 6ef744572a..9a80a1c890 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -160,6 +160,30 @@ async function prepare( } describe("executor runtime preparation", () => { + for (const selection of [undefined, [], ["load_skill"]]) { + it(`normalizes implicit disabled skill tools while retaining explicit rejection (${JSON.stringify(selection)})`, async () => { + const f = fixture({ + config: { tools: true, skills: false }, + grant: { ...grant, allowedToolNames: ["load_skill"], hostToolFacadeIds: ["skills"] }, + facades: { hostTools: new Map([["skills", { load_skill: syntheticHostTool() }]]) }, + }); + try { + const result = await prepare(f.owner, { + agentId: "coder", + ...(selection === undefined ? {} : { allowedToolNames: selection }), + }); + if (selection?.length) { + assertEquals(result, { ok: false, code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE" }); + } else { + assert(result && typeof result === "object" && !Array.isArray(result)); + assertEquals(result.ok, true); + } + } finally { + await f.owner.close(); + } + }); + } + it("keeps skill references and scripts outside a loader-only grant after loading a skill", async () => { const visible: string[][] = []; const f = fixture({ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index f347b2e3dd..3632305a1f 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -575,9 +575,9 @@ export function createExecutorRuntimePreparation(input: Options) { agentId: definition.id, selector: definition.skills === false ? [] : definition.skills, }); - if (sourceToolNames !== undefined) { + if (sourceToolNames !== undefined || request.allowedToolNames === undefined) { const effectiveSourceTools = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: normalizeToolNames(sourceToolNames), + allowedToolNames: normalizeToolNames(sourceToolNames ?? allowedToolNames), localToolNames: filter( normalizeToolNames(grant.allowedToolNames), (name) => hasOwn(localTools, name), From 4f19fa6be77f08a6e02439702502e4c8c4c24884 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 11:03:59 +0200 Subject: [PATCH 137/194] fix(agent): protect validation scans and buffered recovery output --- src/agent/middleware/security/validator.ts | 63 +++++++++++-------- src/agent/runtime/index.ts | 13 ++-- ...neration-runtime-message-converter.test.ts | 35 +++++++++++ ...xt-generation-runtime-message-converter.ts | 62 +++++++++--------- src/security/private-array.test.ts | 63 +++++++++++++++++++ src/security/private-array.ts | 23 +++++++ .../executor-iterator-intrinsics.test.ts | 16 +++++ .../executor-recovery-text-intrinsics.test.ts | 11 +++- 8 files changed, 226 insertions(+), 60 deletions(-) diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 51df617ab7..9009c88b47 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -2,10 +2,13 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts" import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { concatPrivateArrays, + everyPrivateArray, filterPrivateArray, + findLastPrivateArrayIndex, flatMapPrivateArray, joinPrivateArray, mapPrivateArray, + somePrivateArray, } from "#veryfront/security/private-array.ts"; import { isDeepStrictEqual } from "node:util"; import type { @@ -660,11 +663,9 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { // layers, so keep the original runs above and validate this alternate view // in addition. for (const run of extractAdjacentRuns(messages, "system", true)) { - const alreadyCovered = runs.some( - (candidate) => - candidate.length === run.length && - candidate.every((message, index) => message === run[index]), - ); + const alreadyCovered = somePrivateArray(runs, (candidate) => + candidate.length === run.length && + everyPrivateArray(candidate, (message, index) => message === run[index])); if (!alreadyCovered) runs.push(run); } @@ -677,11 +678,9 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { // A single system message is covered by the per-message extraction. if (hoisted.length < 2) return runs; - const alreadyCovered = runs.some( - (run) => - run.length === hoisted.length && - run.every((message, index) => message === hoisted[index]), - ); + const alreadyCovered = somePrivateArray(runs, (run) => + run.length === hoisted.length && + everyPrivateArray(run, (message, index) => message === hoisted[index])); return alreadyCovered ? runs : [...runs, hoisted]; } @@ -753,13 +752,15 @@ function extractMergedRunTexts( // would also exempt a newly joined boundary that happens to duplicate a // different historical run, or a run shortened by trimming. if ( - previousRuns?.some((previous) => + previousRuns !== undefined && + somePrivateArray(previousRuns, (previous) => previous.length === run.length && - previous.every((message, index) => sameOccurrence?.(message, run[index])) - ) + everyPrivateArray(previous, (message, index) => sameOccurrence?.(message, run[index]))) ) continue; - if (mustInclude && !run.some((message) => mustInclude.has(message))) continue; - if (mustAlsoInclude && !run.some((message) => mustAlsoInclude.has(message))) continue; + if (mustInclude && !somePrivateArray(run, (message) => mustInclude.has(message))) continue; + if (mustAlsoInclude && !somePrivateArray(run, (message) => mustAlsoInclude.has(message))) { + continue; + } for (const partSeparator of ASSEMBLED_TEXT_SEPARATORS) { // The OpenAI-compatible converter and the Anthropic builder join system // run members with a blank line, but the Google builder sends each @@ -882,7 +883,7 @@ function sanitizeStructuredInput(validator: InputValidator, messages: Message[]) // into one fully sanitized part instead of being kept apart. const textValues = mapPrivateArray(filterPrivateArray(parts, isTextPart), (part) => part.text); const assembledNeedsRewrite = textValues.length > 1 && - ASSEMBLED_TEXT_SEPARATORS.some((separator) => { + somePrivateArray(ASSEMBLED_TEXT_SEPARATORS, (separator) => { const assembled = joinPrivateArray(textValues, separator); return (validator.sanitize(assembled) ?? assembled) !== assembled; }); @@ -999,7 +1000,7 @@ async function validateInputTexts( ), ]); return { - valid: results.every((result) => result.valid), + valid: everyPrivateArray(results, (result) => result.valid), violations: flatMapPrivateArray(results, (result) => result.violations), }; } @@ -1375,10 +1376,13 @@ async function assertProviderRunsValid( }), ), ); - const introduced = patternOccurrences(text, pattern).some((match) => - !trustedMatches.some((trusted) => - trusted.index === match.index && trusted.text === match.text - ) + const introduced = somePrivateArray( + patternOccurrences(text, pattern), + (match) => + !somePrivateArray( + trustedMatches, + (trusted) => trusted.index === match.index && trusted.text === match.text, + ), ); if (introduced) introducedViolations.push(violation); } @@ -1493,7 +1497,7 @@ function sanitizeAgentInput( function sameTexts(left: InputValidationTexts, right: InputValidationTexts): boolean { const sameList = (a: string[], b: string[]) => - a.length === b.length && a.every((value, index) => value === b[index]); + a.length === b.length && everyPrivateArray(a, (value, index) => value === b[index]); return sameList(left.texts, right.texts) && sameList(left.assembled, right.assembled); } @@ -1538,8 +1542,10 @@ export function securityMiddleware( for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]!; if (message.role !== "system") continue; - const current = pendingCurrent.findLastIndex((input) => - input === message || input.id === message.id && isDeepStrictEqual(input, message) + const current = findLastPrivateArrayIndex( + pendingCurrent, + (input) => + input === message || input.id === message.id && isDeepStrictEqual(input, message), ); if (current < 0) continue; pendingCurrent.splice(current, 1); @@ -1556,8 +1562,8 @@ export function securityMiddleware( const run of extractMergedSystemRuns(concatPrivateArrays(systemMessages, callerMessages)) ) { if ( - !run.some((message) => trusted.has(message)) || - !run.some((message) => callers.has(message)) + !somePrivateArray(run, (message) => trusted.has(message)) || + !somePrivateArray(run, (message) => callers.has(message)) ) continue; // Runtime and historical text have separate exemptions. A new match // across their boundary must still be checked when the runtime changes. @@ -1727,7 +1733,10 @@ export function securityMiddleware( const resolvedTexts = extractInputValidationTexts(messages); const sameMessageIdentity = approvedMessages !== undefined && approvedMessages.length === messages.length && - approvedMessages.every((message, index) => message.id === messages[index]?.id); + everyPrivateArray( + approvedMessages, + (message, index) => message.id === messages[index]?.id, + ); const roleRewriteCandidates = approvedMessages === undefined ? filterPrivateArray(messages, (message) => !VALIDATED_INPUT_ROLES.has(message.role)) : filterPrivateArray( diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 5145416fde..a8ad094bd6 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -3590,7 +3590,9 @@ export class AgentRuntime { let remainingSsePrefixLength = interruptedRecoveryPrefixLength; let remainingCallbackPrefixLength = interruptedRecoveryPrefixLength; - for (const output of deferredRecoveryOutput) { + for (let outputIndex = 0; outputIndex < deferredRecoveryOutput.length; outputIndex++) { + if (!ObjectHasOwn(deferredRecoveryOutput, outputIndex)) continue; + const output = deferredRecoveryOutput[outputIndex]!; if ( repeatsInterruptedRecoveryText && (output.kind === "callback" || output.isTextEvent) @@ -3679,10 +3681,13 @@ export class AgentRuntime { return; } - const retainedOutput = deferredRecoveryOutput.filter((output) => - output.kind === "callback" || output.isTextEvent + const retainedOutput = filterPrivateArray( + deferredRecoveryOutput, + (output) => output.kind === "callback" || output.isTextEvent, ); - for (const output of deferredRecoveryOutput) { + for (let outputIndex = 0; outputIndex < deferredRecoveryOutput.length; outputIndex++) { + if (!ObjectHasOwn(deferredRecoveryOutput, outputIndex)) continue; + const output = deferredRecoveryOutput[outputIndex]!; if (output.kind === "sse" && !output.isTextEvent) { enqueuePrivateStream(controller, output.chunk); } diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 2607669c20..036dcbe526 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -5,6 +5,7 @@ import { convertToTextGenerationRuntimeMessage, convertToTextGenerationRuntimeMessages, convertToTextGenerationRuntimeRequestMessages, + getAnthropicCompactedAssistantMessages, } from "./text-generation-runtime-message-converter.ts"; import type { TextGenerationRuntimeAssistantMessage, @@ -16,6 +17,40 @@ import type { Message } from "../types.ts"; import { attachProviderMetadata, markProviderReplayDelivered } from "./provider-metadata.ts"; describe("text-generation-runtime-message-converter", () => { + it("compacts completed historical tool rounds without consulting the input reverse scan", () => { + const messages: Message[] = [ + { + id: "user-first", + role: "user", + parts: [{ type: "text", text: "Synthetic first request" }], + }, + { + id: "tool-round", + role: "assistant", + parts: [{ type: "tool-call", toolCallId: "call", toolName: "inspect", args: {} }], + }, + { + id: "answer", + role: "assistant", + parts: [{ type: "text", text: "Synthetic historical answer" }], + }, + { + id: "user-current", + role: "user", + parts: [{ type: "text", text: "Synthetic current request" }], + }, + ]; + let observations = 0; + Object.defineProperty(messages, "findLastIndex", { + get() { + observations++; + return Array.prototype.findLastIndex; + }, + }); + assertEquals([...getAnthropicCompactedAssistantMessages(messages)], [messages[1]]); + assertEquals(observations, 0); + }); + it("converts history without consulting caller-owned part iterators", () => { const parts: Message["parts"] = [{ type: "text", text: "Synthetic model text" }]; let observations = 0; diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 6dd51a0b29..12c1e121da 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -1,3 +1,5 @@ +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { privateTextEndsWith, privateTextIncludes, @@ -6,9 +8,11 @@ import { } from "#veryfront/security/private-text.ts"; import { appendPrivateArray, + findLastPrivateArrayIndex, flatMapPrivateArray, mapPrivateArray, pushPrivateArray, + somePrivateArray, } from "#veryfront/security/private-array.ts"; /** * Text-Generation Runtime Message Converter @@ -125,7 +129,7 @@ function getToolInputRecord(part: Record): Record = new Set(), + providerExecutedToolCallIds: ReadonlySet = createPrivateSet(), ): TextGenerationRuntimeToolCallPart | null { if (!isRecord(part) || typeof part.type !== "string") { return null; @@ -328,7 +332,8 @@ export function convertToTextGenerationRuntimeMessage( & { providerExecutedToolCallIds?: Set } & TextGenerationRuntimeConversionOptions = {}, ): TextGenerationRuntimeMessage { - const providerExecutedToolCallIds = options.providerExecutedToolCallIds ?? new Set(); + const providerExecutedToolCallIds = options.providerExecutedToolCallIds ?? + createPrivateSet(); addProviderMetadataToolCallIds(msg, providerExecutedToolCallIds); const requireInternetReachableAttachments = options.requireInternetReachableAttachments ?? true; @@ -398,7 +403,7 @@ export function convertToTextGenerationRuntimeMessage( case "tool": { const content: TextGenerationRuntimeToolMessage["content"] = []; - const toolNamesById = new Map(); + const toolNamesById = createPrivateMap(); for (let partIndex = 0; partIndex < msg.parts.length; partIndex++) { if (!hasOwn(msg.parts, partIndex)) continue; @@ -438,7 +443,7 @@ export function convertToTextGenerationRuntimeMessage( */ export function hasProviderSendableAssistantContent( message: Message, - priorProviderExecutedToolCallIds: ReadonlySet = new Set(), + priorProviderExecutedToolCallIds: ReadonlySet = createPrivateSet(), ): boolean { if (message.role !== "assistant") return true; if (readAttachedProviderMetadata(message) !== undefined) return true; @@ -447,7 +452,7 @@ export function hasProviderSendableAssistantContent( // ordinary call in the same assistant message. Mirror that state here so a // duplicate ID cannot make the predicate claim content that conversion will // remove. - const providerExecutedToolCallIds = new Set(priorProviderExecutedToolCallIds); + const providerExecutedToolCallIds = createPrivateSet(priorProviderExecutedToolCallIds); for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { if (!hasOwn(message.parts, partIndex)) continue; const part = message.parts[partIndex]!; @@ -476,8 +481,8 @@ export function hasProviderSendableAssistantContent( export function getProviderSendableAssistantMessages( messages: readonly Message[], ): ReadonlySet { - const sendable = new Set(); - const providerExecutedToolCallIds = new Set(); + const sendable = createPrivateSet(); + const providerExecutedToolCallIds = createPrivateSet(); for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex]!; if (message.role === "user" || message.role === "system") { @@ -517,8 +522,8 @@ export function getProviderSendableAssistantMessages( export function getProviderSendableToolMessages( messages: readonly Message[], ): ReadonlySet { - const sendable = new Set(); - const providerExecutedToolCallIds = new Set(); + const sendable = createPrivateSet(); + const providerExecutedToolCallIds = createPrivateSet(); for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex]!; if (message.role === "user" || message.role === "system") { @@ -549,18 +554,19 @@ export function getProviderSendableToolMessages( export function getAnthropicCompactedAssistantMessages( messages: readonly Message[], ): ReadonlySet { - const compacted = new Set(); - const lastUserIndex = messages.findLastIndex((message) => message.role === "user"); - const lastHistoricalAssistantTextIndex = messages.findLastIndex((message, index) => - index < lastUserIndex && - message.role === "assistant" && - message.parts.some((part) => - part.type === "text" && "text" in part && - typeof (part as { text?: unknown }).text === "string" && - (part as { text: string }).text.length > 0 - ) + const compacted = createPrivateSet(); + const lastUserIndex = findLastPrivateArrayIndex(messages, (message) => message.role === "user"); + const lastHistoricalAssistantTextIndex = findLastPrivateArrayIndex( + messages, + (message, index) => + index < lastUserIndex && + message.role === "assistant" && + somePrivateArray(message.parts, (part) => + part.type === "text" && "text" in part && + typeof (part as { text?: unknown }).text === "string" && + (part as { text: string }).text.length > 0), ); - const providerExecutedToolCallIds = new Set(); + const providerExecutedToolCallIds = createPrivateSet(); for (let index = 0; index < messages.length; index++) { const message = messages[index]!; @@ -577,14 +583,14 @@ export function getAnthropicCompactedAssistantMessages( if ( message.role === "assistant" && index < lastHistoricalAssistantTextIndex && - message.parts.some((part) => - getTextGenerationToolCallPart(part, providerExecutedToolCallIds) !== null + somePrivateArray( + message.parts, + (part) => getTextGenerationToolCallPart(part, providerExecutedToolCallIds) !== null, ) && - !message.parts.some((part) => + !somePrivateArray(message.parts, (part) => part.type === "text" && "text" in part && typeof (part as { text?: unknown }).text === "string" && - (part as { text: string }).text.length > 0 - ) + (part as { text: string }).text.length > 0) ) { compacted.add(message); } @@ -623,8 +629,8 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( const assistantContent: TextGenerationRuntimeAssistantMessage["content"] = []; const deferredAssistantContent: TextGenerationRuntimeAssistantMessage["content"] = []; const toolResults: TextGenerationRuntimeToolMessage["content"] = []; - const pendingToolCallIds = new Set(); - const toolNamesById = new Map(); + const pendingToolCallIds = createPrivateSet(); + const toolNamesById = createPrivateMap(); const messages: TextGenerationRuntimeMessage[] = []; const flushAssistantMessage = (content: TextGenerationRuntimeAssistantMessage["content"]) => { @@ -774,7 +780,7 @@ export function convertToTextGenerationRuntimeMessages( options: TextGenerationRuntimeConversionOptions = {}, ): TextGenerationRuntimeMessage[] { const textGenerationRuntimeMessages: TextGenerationRuntimeMessage[] = []; - const providerExecutedToolCallIds = new Set(); + const providerExecutedToolCallIds = createPrivateSet(); for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex]!; diff --git a/src/security/private-array.test.ts b/src/security/private-array.test.ts index 8cccb2f9b0..1a2d01214a 100644 --- a/src/security/private-array.test.ts +++ b/src/security/private-array.test.ts @@ -3,13 +3,76 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { appendPrivateArray, concatPrivateArrays, + everyPrivateArray, filterPrivateArray, + findLastPrivateArrayIndex, flatMapPrivateArray, joinPrivateArray, pushPrivateArray, } from "./private-array.ts"; describe("private array concatenation", () => { + it("checks and reverse-scans own entries with early termination", () => { + const values = [1, , 3]; + let observations = 0; + Object.setPrototypeOf( + values, + Object.create(Array.prototype, { + 1: { + get() { + observations++; + return 2; + }, + }, + every: { + get() { + observations++; + return Array.prototype.every; + }, + }, + findLastIndex: { + get() { + observations++; + return Array.prototype.findLastIndex; + }, + }, + }), + ); + const indexes: number[] = []; + assertEquals( + everyPrivateArray(values, (value, index, source) => { + assertStrictEquals(source, values); + indexes.push(index); + return value !== undefined && value < 5; + }), + true, + ); + assertEquals(indexes, [0, 2]); + indexes.length = 0; + assertEquals( + everyPrivateArray(values, (_value, index) => { + indexes.push(index); + return false; + }), + false, + ); + assertEquals(indexes, [0]); + indexes.length = 0; + assertEquals( + findLastPrivateArrayIndex(values, (value, index) => { + indexes.push(index); + return value === 3; + }), + 2, + ); + assertEquals(indexes, [2]); + assertEquals(findLastPrivateArrayIndex(values, (value) => value === 1), 0); + assertEquals(findLastPrivateArrayIndex(values, (value) => value === 9), -1); + assertEquals(everyPrivateArray([], () => false), true); + assertEquals(findLastPrivateArrayIndex([], () => true), -1); + assertEquals(observations, 0); + }); + it("filters own values while preserving callback context and element identity", () => { const first = { value: 1 }; const second = { value: 2 }; diff --git a/src/security/private-array.ts b/src/security/private-array.ts index c5a8ef9036..df56bc5fc5 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -4,6 +4,29 @@ const hasOwn = Object.hasOwn; const isArray = Array.isArray; const apply = Reflect.apply; +/** Require every own entry to pass, without consulting mutable array methods. */ +export function everyPrivateArray( + values: readonly T[], + predicate: (value: T, index: number, values: readonly T[]) => unknown, +): boolean { + const length = values.length; + for (let index = 0; index < length; index++) { + if (hasOwn(values, index) && !predicate(values[index]!, index, values)) return false; + } + return true; +} + +/** Find the last matching own entry without exposing the source to a reverse-scan hook. */ +export function findLastPrivateArrayIndex( + values: readonly T[], + predicate: (value: T, index: number, values: readonly T[]) => unknown, +): number { + for (let index = values.length - 1; index >= 0; index--) { + if (hasOwn(values, index) && predicate(values[index]!, index, values)) return index; + } + return -1; +} + /** Test private array elements without exposing the receiver to writable methods. */ export function somePrivateArray( values: readonly T[], diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 06af24f3d3..29d3887649 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -20,6 +20,8 @@ describe("prepared executor private iteration", () => { "array mapping", "array flattening", "array joining", + "array every", + "array last-index", "promise chaining", "regexp testing", "regexp execution", @@ -123,6 +125,8 @@ describe("prepared executor private iteration", () => { const originalTest = RegExp.prototype.test; const originalExec = RegExp.prototype.exec; const originalSome = Array.prototype.some; + const originalEvery = Array.prototype.every; + const originalFindLastIndex = Array.prototype.findLastIndex; const originalSignature = Object.getOwnPropertyDescriptor(Object.prototype, "signature"); const originalMetadata = Object.getOwnPropertyDescriptor(Object.prototype, "metadata"); const originalNext = prototype.next; @@ -179,6 +183,16 @@ describe("prepared executor private iteration", () => { if (input.includes(marker)) observations++; return Reflect.apply(originalExec, this, [input]); }; + } else if (probe === "array every") { + Array.prototype.every = (function (this: unknown[], ...args: unknown[]) { + observeTextArray(this); + return Reflect.apply(originalEvery, this, args); + }) as typeof originalEvery; + } else if (probe === "array last-index") { + Array.prototype.findLastIndex = function (...args) { + observeTextArray(this); + return Reflect.apply(originalFindLastIndex, this, args); + }; } else if (probe === "reasoning scans") { Array.prototype.some = (function (this: unknown[], ...args: unknown[]) { observeTextArray(this); @@ -287,6 +301,8 @@ describe("prepared executor private iteration", () => { RegExp.prototype.test = originalTest; RegExp.prototype.exec = originalExec; Array.prototype.some = originalSome; + Array.prototype.every = originalEvery; + Array.prototype.findLastIndex = originalFindLastIndex; if (originalSignature) { Object.defineProperty(Object.prototype, "signature", originalSignature); } else Reflect.deleteProperty(Object.prototype, "signature"); diff --git a/tests/integration/agent/executor-recovery-text-intrinsics.test.ts b/tests/integration/agent/executor-recovery-text-intrinsics.test.ts index 843158a511..2b1e4b9257 100644 --- a/tests/integration/agent/executor-recovery-text-intrinsics.test.ts +++ b/tests/integration/agent/executor-recovery-text-intrinsics.test.ts @@ -11,14 +11,17 @@ import { defineSchema } from "#veryfront/schemas/index.ts"; for (const replaceMethods of [false, true]) { describe(`private recovery text ${replaceMethods ? "hooks" : "baseline"}`, () => { - it("deduplicates replayed text without invoking replaced string methods", async () => { + it("deduplicates replayed text without invoking replaced string or filter methods", async () => { const marker = "synthetic-private-recovery-marker"; const originalStartsWith = String.prototype.startsWith; const originalSlice = String.prototype.slice; const originalIncludes = String.prototype.includes; + const originalFilter = Array.prototype.filter; + const stringify = JSON.stringify; const apply = Reflect.apply; let calls = 0; let observations = 0; + let filterObservations = 0; let finished: AgentResponse | undefined; const chunks: string[] = []; const model: ModelRuntime = { @@ -62,6 +65,10 @@ for (const replaceMethods of [false, true]) { } as RuntimeToolFilterConfig, { resolveModelRuntime: () => model }); try { if (replaceMethods) { + Array.prototype.filter = (function (this: unknown[], ...args: unknown[]) { + if (apply(originalIncludes, stringify(this) ?? "", [marker])) filterObservations++; + return apply(originalFilter, this, args); + }) as typeof originalFilter; String.prototype.startsWith = function (search, position) { if (apply(originalIncludes, this, [marker])) observations++; return apply(originalStartsWith, this, [search, position]); @@ -81,6 +88,7 @@ for (const replaceMethods of [false, true]) { await result.toDataStreamResponse().text(); } finally { if (replaceMethods) { + Array.prototype.filter = originalFilter; String.prototype.startsWith = originalStartsWith; String.prototype.slice = originalSlice; } @@ -89,6 +97,7 @@ for (const replaceMethods of [false, true]) { assertEquals(chunks, [marker + " complete"]); assertEquals((finished as AgentResponse | undefined)?.text, marker + " complete"); assertEquals(observations, 0); + assertEquals(filterObservations, 0); }); }); } From cb51743e38258b070c183d548ebd8c83c0b7f64f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 11:18:42 +0200 Subject: [PATCH 138/194] fix(agent): normalize owned broker tool grants --- .../hosted/managed-executor-broker.test.ts | 50 ++++++++++++++++++- src/agent/hosted/managed-executor-broker.ts | 6 +++ src/discovery/agent-capability-namespace.ts | 14 ++++++ src/discovery/agent-scoped-capabilities.ts | 20 +++----- 4 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 src/discovery/agent-capability-namespace.ts diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index f8ae274a5c..d5e2006670 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -42,6 +42,7 @@ function runtimeModel(): ModelRuntime { function fixture( options: { + agentId?: string; prepareFailure?: boolean; prepareModelId?: string; brokerReadWait?: boolean; @@ -161,7 +162,7 @@ function fixture( value: { source, definition: { - id: "coder", + id: options.agentId ?? "coder", name: "Coder", description: "Codes", instructions: "Work", @@ -394,6 +395,53 @@ describe("managed executor broker", () => { } }); + for ( + const [agentId, selector, capabilityName, accepted] of [ + ["coder", "fetch-paper", "coder--fetch-paper", true], + ["research.coder", "fetch-paper", "research_coder--fetch-paper", true], + ["coder", "fetch-paper", "writer--fetch-paper", false], + ["coder", "coder--fetch-paper", "writer--fetch-paper", false], + ["coder", "coder--fetch-paper", "fetch-paper", false], + ] as const + ) { + it(`${accepted ? "accepts" : "rejects"} capability ${capabilityName} for ${agentId}'s ${selector} grant`, async () => { + const f = fixture({ agentId }); + f.input.installation.grant.agentId = agentId; + f.input.prepare.agentId = agentId; + f.input.installation.grant.allowedToolNames = [selector]; + f.input.tools.sources = new Map([["synthetic", { + source: { + id: "synthetic", + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ result: "done" }), + }, + allowedToolNames: new Set([capabilityName]), + context: {}, + }]]); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + let runtime: Awaited> | undefined; + try { + if (accepted) { + runtime = await broker.start(f.input); + assertEquals(f.calls.includes("allocate"), true); + } else { + await assertRejects( + async () => { + runtime = await broker.start(f.input); + }, + TypeError, + "exceeds the installed", + ); + assertEquals(f.calls, []); + } + } finally { + await runtime?.close(); + await broker.shutdown(); + await broker.settled; + } + }); + } + it("installs, discovers, prepares, accepts, and begins execution in exact order", async () => { const f = fixture({ initialCheckpoint: true }); const broker = createManagedExecutorBroker({ maxActive: 1 }); diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index b1d8d70628..da0de77a74 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -1,3 +1,4 @@ +import { namespaceAgentCapability } from "#veryfront/discovery/agent-capability-namespace.ts"; import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; import type { AgentModelRuntimeResolver } from "../runtime/model-transport.ts"; @@ -307,6 +308,11 @@ function assertInstalledOperationGrants( } } const allowedTools = new Set(installation.grant.allowedToolNames); + // Trusted source capabilities carry canonical IDs, while an installed selector + // can name a tool relative to the owning agent's namespace. + for (const selector of installation.grant.allowedToolNames) { + allowedTools.add(namespaceAgentCapability(installation.grant.agentId, selector)); + } for (const capability of input.tools.sources.values()) { for (const name of capability.allowedToolNames) { if (!allowedTools.has(name)) { diff --git a/src/discovery/agent-capability-namespace.ts b/src/discovery/agent-capability-namespace.ts new file mode 100644 index 0000000000..e950fbf1b4 --- /dev/null +++ b/src/discovery/agent-capability-namespace.ts @@ -0,0 +1,14 @@ +/** Separator between the agent namespace and the capability short name. */ +export const AGENT_CAPABILITY_NAMESPACE_SEPARATOR = "--"; + +/** Sanitizes an agent id into a provider-safe namespace segment. */ +export function sanitizeCapabilityNamespace(agentId: string): string { + return agentId.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +/** Namespaces a capability short name under its owning agent. */ +export function namespaceAgentCapability(agentId: string, shortName: string): string { + return `${ + sanitizeCapabilityNamespace(agentId) + }${AGENT_CAPABILITY_NAMESPACE_SEPARATOR}${shortName}`; +} diff --git a/src/discovery/agent-scoped-capabilities.ts b/src/discovery/agent-scoped-capabilities.ts index 8837aa860d..9a8526cac7 100644 --- a/src/discovery/agent-scoped-capabilities.ts +++ b/src/discovery/agent-scoped-capabilities.ts @@ -1,3 +1,9 @@ +import { namespaceAgentCapability } from "./agent-capability-namespace.ts"; +export { + AGENT_CAPABILITY_NAMESPACE_SEPARATOR, + namespaceAgentCapability, + sanitizeCapabilityNamespace, +} from "./agent-capability-namespace.ts"; /** * Per-agent colocated capability registration. * @@ -38,8 +44,6 @@ import type { DiscoveryResult, FileDiscoveryContext } from "./types.ts"; const AGENT_TOOLS_SUBDIR = "tools"; const AGENT_SKILLS_SUBDIR = "skills"; -/** Separator between the agent namespace and the capability short name. */ -export const AGENT_CAPABILITY_NAMESPACE_SEPARATOR = "--"; /** Provider tool-call names allow only this charset, max 64 chars. */ const PROVIDER_TOOL_NAME_REGEX = /^[A-Za-z0-9_-]{1,64}$/; @@ -59,18 +63,6 @@ export function isSafePathSegment(name: string): boolean { return name !== "." && name !== ".." && SAFE_PATH_SEGMENT_REGEX.test(name); } -/** Sanitizes an agent id into a provider-safe namespace segment. */ -export function sanitizeCapabilityNamespace(agentId: string): string { - return agentId.replace(/[^A-Za-z0-9_-]/g, "_"); -} - -/** Namespaces a capability short name under its owning agent. */ -export function namespaceAgentCapability(agentId: string, shortName: string): string { - return `${ - sanitizeCapabilityNamespace(agentId) - }${AGENT_CAPABILITY_NAMESPACE_SEPARATOR}${shortName}`; -} - function isTool(value: unknown): value is Tool { return value !== null && typeof value === "object" && typeof (value as Tool).execute === "function"; From 29f15a1a94b3d269d075d1e10349b5ae60d01198 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 11:19:59 +0200 Subject: [PATCH 139/194] fix(agent): protect private runtime and artifact policy dispatch --- .../slash-command-artifact-policy.ts | 60 ++++++++++++------- src/agent/runtime/index.ts | 27 +++++++++ .../executor-iterator-intrinsics.test.ts | 25 +++++++- ...tor-provider-conversion-intrinsics.test.ts | 41 ++++++++++++- .../slash-command-policy-intrinsics.test.ts | 46 ++++++++++++++ 5 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 tests/integration/agent/slash-command-policy-intrinsics.test.ts diff --git a/src/agent/artifacts/slash-command-artifact-policy.ts b/src/agent/artifacts/slash-command-artifact-policy.ts index 55bb057e9d..71f5e968bb 100644 --- a/src/agent/artifacts/slash-command-artifact-policy.ts +++ b/src/agent/artifacts/slash-command-artifact-policy.ts @@ -1,6 +1,18 @@ -import { somePrivateArray } from "#veryfront/security/private-array.ts"; +import { flatMapPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { isRecord } from "#veryfront/chat/conversation.ts"; +const regexpExec = RegExp.prototype.exec; +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const objectValues = Object.values; +const parseJson = JSON.parse; +const stringTrim = String.prototype.trim; + +function matches(pattern: RegExp, value: string): boolean { + return apply(regexpExec, pattern, [value]) !== null; +} + const SLASH_COMMAND_PATTERN = /(?:^|)\s*\/[a-z0-9_-]+/i; const EXACT_ARTIFACT_PATH_PATTERN = /(?:^|[\s`"'(])\/?[\w./-]+\.(?:md|mdx|txt|json|ya?ml)\b/i; @@ -51,7 +63,7 @@ function isToolRoleMessage(message: unknown): message is { function parseJsonString(value: string): unknown { try { - return JSON.parse(value); + return parseJson(value); } catch { return value; } @@ -59,36 +71,39 @@ function parseJsonString(value: string): unknown { function extractArtifactPathsFromUnknown(value: unknown): string[] { if (typeof value === "string") { - return EXACT_ARTIFACT_PATH_PATTERN.test(value) ? [value] : []; + return matches(EXACT_ARTIFACT_PATH_PATTERN, value) ? [value] : []; } - if (Array.isArray(value)) { - return value.flatMap((item) => extractArtifactPathsFromUnknown(item)); + if (arrayIsArray(value)) { + return flatMapPrivateArray(value, (item) => extractArtifactPathsFromUnknown(item)); } if (!isRecord(value)) { return []; } - return Object.values(value).flatMap((nestedValue) => - extractArtifactPathsFromUnknown(nestedValue) + return flatMapPrivateArray( + objectValues(value), + (nestedValue) => extractArtifactPathsFromUnknown(nestedValue), ); } function extractMessageTexts(content: unknown): string[] { - if (typeof content === "string" && content.trim().length > 0) { + if (typeof content === "string" && apply(stringTrim, content, []).length > 0) { return [content]; } - if (!Array.isArray(content)) { + if (!arrayIsArray(content)) { return []; } - return content.flatMap((part) => - isRecord(part) && part.type === "text" && typeof part.text === "string" && - part.text.trim().length > 0 - ? [part.text] - : [] + return flatMapPrivateArray( + content, + (part) => + isRecord(part) && part.type === "text" && typeof part.text === "string" && + apply(stringTrim, part.text, []).length > 0 + ? [part.text] + : [], ); } @@ -105,7 +120,7 @@ function resolveToolName( function hasToolCallOrResult(messages: readonly unknown[], toolName: string): boolean { return somePrivateArray(messages, (message) => { - if (!isRecord(message) || !Array.isArray(message.content)) { + if (!isRecord(message) || !arrayIsArray(message.content)) { return false; } @@ -128,21 +143,22 @@ function containsSlashCommand(messages: readonly unknown[]): boolean { return somePrivateArray( extractMessageTexts(message.content), - (text) => SLASH_COMMAND_PATTERN.test(text), + (text) => matches(SLASH_COMMAND_PATTERN, text), ); }); } function containsExactArtifactPath(messages: readonly unknown[]): boolean { - const toolCallNamesById = new Map(); + const toolCallNamesById = createPrivateMap(); for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex]!; - if (!isRecord(message) || !Array.isArray(message.content)) { + if (!isRecord(message) || !arrayIsArray(message.content)) { continue; } - for (const part of message.content) { + for (let partIndex = 0; partIndex < message.content.length; partIndex++) { + const part = message.content[partIndex]; if (!isToolCallPart(part)) { continue; } @@ -159,11 +175,11 @@ function containsExactArtifactPath(messages: readonly unknown[]): boolean { if (message.role === "user") { return somePrivateArray( extractMessageTexts(message.content), - (text) => EXACT_ARTIFACT_PATH_PATTERN.test(text), + (text) => matches(EXACT_ARTIFACT_PATH_PATTERN, text), ); } - if (isToolRoleMessage(message) && !Array.isArray(message.content)) { + if (isToolRoleMessage(message) && !arrayIsArray(message.content)) { const resolvedToolName = resolveToolName(toolCallNamesById, message); if (resolvedToolName !== "form_input") { @@ -176,7 +192,7 @@ function containsExactArtifactPath(messages: readonly unknown[]): boolean { return containsExactArtifactPathValue(parsedContent); } - if (!Array.isArray(message.content)) { + if (!arrayIsArray(message.content)) { return false; } diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index a8ad094bd6..95d95786e7 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1778,6 +1778,17 @@ export class AgentRuntime { config: AgentConfig, internalOptions: AgentRuntimeInternalOptions = {}, ) { + // TypeScript private methods remain writable prototype properties at runtime. + // Own captured operations keep project prototype hooks out of private turns, + // while preserving public dispatch and existing custom memory behavior. + for (let index = 0; index < agentRuntimePrivateMethodNames.length; index++) { + const name = agentRuntimePrivateMethodNames[index]!; + const descriptor = { + __proto__: null, + value: agentRuntimePrivateMethods[name]!.value, + }; + ObjectDefineProperty(this, name, descriptor); + } this.#modelCallThinking = internalOptions.modelCallThinking; this.#onStreamCompletion = internalOptions.onStreamCompletion; this.#modelResolverState = internalOptions.resolveModelRuntime @@ -4560,6 +4571,22 @@ export class AgentRuntime { } } +const agentRuntimePrivateMethodNames = [ + "restoreInputReplayMetadata", + "prepareTurnMessages", + "createTurnPersistence", + "resolveRuntimeState", + "notifyToolResult", + "createGenerateReplacementTools", + "resolveOutputSchema", + "recordToolError", + "resolveSystemPrompt", + "computeMaxSteps", + "resolveTemperature", + "resolveMaxOutputTokens", +] as const; +const agentRuntimePrivateMethods = ObjectGetOwnPropertyDescriptors(AgentRuntime.prototype); + type ProviderMetadataReconciler = (input: { providerMetadata: Record; suppressedToolCalls: readonly { id: string; name: string }[]; diff --git a/tests/integration/agent/executor-iterator-intrinsics.test.ts b/tests/integration/agent/executor-iterator-intrinsics.test.ts index 29d3887649..0d4848935d 100644 --- a/tests/integration/agent/executor-iterator-intrinsics.test.ts +++ b/tests/integration/agent/executor-iterator-intrinsics.test.ts @@ -1,3 +1,4 @@ +import { AgentRuntime } from "veryfront/agent"; import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; @@ -27,6 +28,7 @@ describe("prepared executor private iteration", () => { "regexp execution", "reasoning scans", "reasoning signatures", + "private runtime methods", ] ) { it(`keeps stream requests and model output out of replaced ${probe}`, async () => { @@ -122,6 +124,10 @@ describe("prepared executor private iteration", () => { const originalFlatMap = Array.prototype.flatMap; const originalJoin = Array.prototype.join; const originalThen = Promise.prototype.then; + const originalTurnPreparation = Object.getOwnPropertyDescriptor( + AgentRuntime.prototype, + "prepareTurnMessages", + ); const originalTest = RegExp.prototype.test; const originalExec = RegExp.prototype.exec; const originalSome = Array.prototype.some; @@ -173,7 +179,17 @@ describe("prepared executor private iteration", () => { }; try { await Promise.all([broker.ready, executor.ready]); - if (probe === "regexp testing") { + if (probe === "private runtime methods") { + Object.defineProperty(AgentRuntime.prototype, "prepareTurnMessages", { + configurable: true, + writable: true, + value: function (this: unknown, ...args: unknown[]) { + observeMessages(args[0]); + if (!originalTurnPreparation) throw new Error("Private runtime called a public hook"); + return Reflect.apply(originalTurnPreparation.value, this, args); + }, + }); + } else if (probe === "regexp testing") { RegExp.prototype.test = function (input) { if (input.includes(marker)) observations++; return Reflect.apply(originalTest, this, [input]); @@ -298,6 +314,13 @@ describe("prepared executor private iteration", () => { Array.prototype.flatMap = originalFlatMap; Array.prototype.join = originalJoin; Promise.prototype.then = originalThen; + if (originalTurnPreparation) { + Object.defineProperty( + AgentRuntime.prototype, + "prepareTurnMessages", + originalTurnPreparation, + ); + } else Reflect.deleteProperty(AgentRuntime.prototype, "prepareTurnMessages"); RegExp.prototype.test = originalTest; RegExp.prototype.exec = originalExec; Array.prototype.some = originalSome; diff --git a/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts b/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts index bbfc0ac951..5abdaede4e 100644 --- a/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts +++ b/tests/integration/agent/executor-provider-conversion-intrinsics.test.ts @@ -2,7 +2,10 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { Message } from "#veryfront/agent/types.ts"; -import { convertToTextGenerationRuntimeMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; +import { + convertToTextGenerationRuntimeMessages, + convertToTextGenerationRuntimeRequestMessages, +} from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; for (const replaceMethods of [false, true]) { describe(`private provider conversion ${replaceMethods ? "hooks" : "baseline"}`, () => { @@ -96,3 +99,39 @@ for (const replaceMethods of [false, true]) { }); }); } + +describe("private provider request tails", () => { + for (const method of ["at", "pop"] as const) { + it(`trims trailing assistant messages without observable ${method} calls`, () => { + const marker = "synthetic-private-provider-tail"; + const messages: Message[] = [ + { id: "user", role: "user", parts: [{ type: "text", text: marker }] }, + { id: "assistant", role: "assistant", parts: [{ type: "text", text: marker }] }, + ]; + const expected = convertToTextGenerationRuntimeRequestMessages(messages); + const original = Array.prototype[method]; + let exposures = 0; + let actual: typeof expected = []; + Object.defineProperty(Array.prototype, method, { + configurable: true, + writable: true, + value: function (this: unknown[], ...args: unknown[]) { + if (JSON.stringify(this).includes(marker)) exposures++; + return Reflect.apply(original, this, args); + }, + }); + try { + actual = convertToTextGenerationRuntimeRequestMessages(messages); + } finally { + Object.defineProperty(Array.prototype, method, { + configurable: true, + writable: true, + value: original, + }); + } + assertEquals(actual, expected); + assertEquals(actual, [{ role: "user", content: marker }]); + assertEquals(exposures, 0); + }); + } +}); diff --git a/tests/integration/agent/slash-command-policy-intrinsics.test.ts b/tests/integration/agent/slash-command-policy-intrinsics.test.ts new file mode 100644 index 0000000000..23549f7ec3 --- /dev/null +++ b/tests/integration/agent/slash-command-policy-intrinsics.test.ts @@ -0,0 +1,46 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { evaluateSlashCommandArtifactPolicy } from "#veryfront/agent/artifacts/slash-command-artifact-policy.ts"; + +describe("private slash command artifact policy", () => { + for (const probe of ["test", "exec"] as const) { + it(`keeps prompt and artifact text out of replaced RegExp ${probe}`, () => { + const marker = "synthetic-policy-private"; + const messages = [ + { role: "user", content: `/research ${marker} notes.md` }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "skill", toolName: "load_skill" }], + }, + { role: "tool", toolName: "form_input", content: { artifact: `${marker}.md` } }, + ]; + const expected = evaluateSlashCommandArtifactPolicy({ messages }); + const original = RegExp.prototype[probe]; + let observations = 0; + let actual; + try { + Object.defineProperty(RegExp.prototype, probe, { + configurable: true, + writable: true, + value: function (this: RegExp, value: string) { + if (value.includes(marker)) observations++; + return Reflect.apply(original, this, [value]); + }, + }); + actual = evaluateSlashCommandArtifactPolicy({ messages }); + // Exercise form results independently from the short-circuiting user path. + evaluateSlashCommandArtifactPolicy({ messages: [messages[2]] }); + } finally { + Object.defineProperty(RegExp.prototype, probe, { + configurable: true, + writable: true, + value: original, + }); + } + assertEquals(actual, expected); + assertEquals(actual?.shouldKeepReminder, true); + assertEquals(observations, 0); + }); + } +}); From 30a7bc850b12e365b3cb14e8e25e8680ee83989b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 11:37:04 +0200 Subject: [PATCH 140/194] fix(agent): verify private artifact traversal boundaries --- .../slash-command-artifact-policy.ts | 3 + src/chat/part-field-access.ts | 4 +- .../artifact-policy-regexp-intrinsics.test.ts | 109 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/integration/agent/artifact-policy-regexp-intrinsics.test.ts diff --git a/src/agent/artifacts/slash-command-artifact-policy.ts b/src/agent/artifacts/slash-command-artifact-policy.ts index 71f5e968bb..45810fa337 100644 --- a/src/agent/artifacts/slash-command-artifact-policy.ts +++ b/src/agent/artifacts/slash-command-artifact-policy.ts @@ -6,6 +6,7 @@ const regexpExec = RegExp.prototype.exec; const apply = Reflect.apply; const arrayIsArray = Array.isArray; const objectValues = Object.values; +const hasOwn = Object.hasOwn; const parseJson = JSON.parse; const stringTrim = String.prototype.trim; @@ -152,12 +153,14 @@ function containsExactArtifactPath(messages: readonly unknown[]): boolean { const toolCallNamesById = createPrivateMap(); for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!hasOwn(messages, messageIndex)) continue; const message = messages[messageIndex]!; if (!isRecord(message) || !arrayIsArray(message.content)) { continue; } for (let partIndex = 0; partIndex < message.content.length; partIndex++) { + if (!hasOwn(message.content, partIndex)) continue; const part = message.content[partIndex]; if (!isToolCallPart(part)) { continue; diff --git a/src/chat/part-field-access.ts b/src/chat/part-field-access.ts index 7e111ae179..f8a7ba7cec 100644 --- a/src/chat/part-field-access.ts +++ b/src/chat/part-field-access.ts @@ -10,9 +10,11 @@ import { type ChatJsonValue, stringifyChatJson, toChatJsonValue } from "./json-v /** JSON-compatible value. Re-exported from `json-value.ts` so both agree by construction. */ export type JsonValue = ChatJsonValue; +const isArray = Array.isArray; + /** Check whether a value is a non-array object. */ export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } /** Return string field. */ diff --git a/tests/integration/agent/artifact-policy-regexp-intrinsics.test.ts b/tests/integration/agent/artifact-policy-regexp-intrinsics.test.ts new file mode 100644 index 0000000000..c323fb0f3b --- /dev/null +++ b/tests/integration/agent/artifact-policy-regexp-intrinsics.test.ts @@ -0,0 +1,109 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + containsExactArtifactPathValue, + evaluateSlashCommandArtifactPolicy, +} from "#veryfront/agent/artifacts/slash-command-artifact-policy.ts"; + +for (const replaceMethods of [false, true]) { + describe(`private artifact patterns ${replaceMethods ? "hooks" : "baseline"}`, () => { + it("matches commands and submitted paths without invoking mutable matching or traversal methods", () => { + const marker = "synthetic-private-artifact"; + const formId = "form-" + marker; + const messages = [ + { role: "user", content: [{ type: "text", text: `/plan ${marker}` }] }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "load", toolName: "load_skill" }, + { type: "tool-call", toolCallId: formId, toolName: "form_input" }, + ], + }, + { + role: "tool", + toolCallId: formId, + content: JSON.stringify({ values: { path: `/plans/${marker}.md` } }), + }, + ]; + const test = RegExp.prototype.test; + const exec = RegExp.prototype.exec; + const includes = String.prototype.includes; + const trim = String.prototype.trim; + const flatMap = Array.prototype.flatMap; + const isArray = Array.isArray; + const values = Object.values; + const parse = JSON.parse; + const set = Map.prototype.set; + const stringify = JSON.stringify; + const apply = Reflect.apply; + let observations = 0; + const observe = (value: unknown) => { + const text = typeof value === "string" ? value : stringify(value) ?? ""; + if (apply(includes, text, [marker])) observations++; + }; + let policy: ReturnType | undefined; + let submittedPath = false; + try { + if (replaceMethods) { + String.prototype.trim = function () { + observe(this); + return apply(trim, this, []); + }; + Array.prototype.flatMap = (function (this: unknown[], ...args: unknown[]) { + observe(this); + return apply(flatMap, this, args); + }) as typeof flatMap; + Array.isArray = function (value): value is unknown[] { + observe(value); + return isArray(value); + }; + Object.values = function (value: unknown) { + observe(value); + return apply(values, undefined, [value]); + }; + JSON.parse = function (text, reviver) { + observe(text); + return parse(text, reviver); + }; + Map.prototype.set = function (key, value) { + observe(key); + observe(value); + return apply(set, this, [key, value]); + }; + RegExp.prototype.test = function (input) { + if (typeof input === "string" && apply(includes, input, [marker])) observations++; + return apply(test, this, [input]); + }; + RegExp.prototype.exec = function (input) { + if (typeof input === "string" && apply(includes, input, [marker])) observations++; + return apply(exec, this, [input]); + }; + } + policy = evaluateSlashCommandArtifactPolicy({ messages }); + submittedPath = containsExactArtifactPathValue({ + values: { paths: [`/plans/${marker}.md`] }, + }); + } finally { + if (replaceMethods) { + String.prototype.trim = trim; + Array.prototype.flatMap = flatMap; + Array.isArray = isArray; + Object.values = values; + JSON.parse = parse; + Map.prototype.set = set; + RegExp.prototype.test = test; + RegExp.prototype.exec = exec; + } + } + assertEquals(policy, { + hasSlashCommand: true, + hasExactArtifactPath: true, + hasLoadSkill: true, + hasInvokeAgent: false, + shouldKeepReminder: true, + }); + assertEquals(submittedPath, true); + assertEquals(observations, 0); + }); + }); +} From 8a27d9da7f549221468559b8b93285cfa8848fb2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 11:53:47 +0200 Subject: [PATCH 141/194] fix(agent): enforce installed tool sources and steering grants --- .../hosted/managed-executor-broker.test.ts | 43 ++++++++++++++++++- src/agent/hosted/managed-executor-broker.ts | 28 ++++++++---- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index d5e2006670..ecb9af7647 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -318,7 +318,7 @@ function configureCanonical( } describe("managed executor broker", () => { - for (const mismatch of ["output tokens", "provider tools", "tool allowlist"]) { + for (const mismatch of ["output tokens", "provider tools", "tool allowlist", "tool source"]) { it(`rejects a broker ${mismatch} grant broader than its installation before allocation`, async () => { const f = fixture(); if (mismatch === "output tokens") { @@ -331,6 +331,8 @@ describe("managed executor broker", () => { args: {}, }]; } else { + if (mismatch === "tool source") f.input.installation.grant.allowedToolNames = ["ungranted"]; + else f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; f.input.tools.sources = new Map([["synthetic", { source: { id: "synthetic", @@ -372,6 +374,7 @@ describe("managed executor broker", () => { args: {}, }]; f.input.installation.grant.allowedToolNames = ["inspect"]; + f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; f.input.tools.sources = new Map([["synthetic", { source: { id: "synthetic", @@ -409,6 +412,7 @@ describe("managed executor broker", () => { f.input.installation.grant.agentId = agentId; f.input.prepare.agentId = agentId; f.input.installation.grant.allowedToolNames = [selector]; + f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; f.input.tools.sources = new Map([["synthetic", { source: { id: "synthetic", @@ -442,6 +446,43 @@ describe("managed executor broker", () => { }); } + it("uses owner-scoped tool grants for steering refresh authorization", async () => { + const f = fixture(); + f.input.installation.grant.allowedToolNames = ["fetch-paper"]; + f.input.installation.capabilities.projectSteering = "steering"; + f.input.state.prepareProjectSteering = ({ definition }) => + Promise.resolve({ agent: definition }); + let selection: readonly string[] | undefined; + f.input.state.refreshProjectSteering = (_signal, names) => { + selection = names; + return Promise.resolve("Refreshed"); + }; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + const runtime = await broker.start(f.input); + try { + runtime.accept({ kind: "execution" }); + assertEquals( + await f.peer!.request(executorStateOperations.refreshProjectSteering, { + capabilityId: "steering", + availableToolNames: ["coder--fetch-paper"], + }), + "Refreshed", + ); + assertEquals(selection, ["coder--fetch-paper"]); + await assertRejects(() => + f.peer!.request(executorStateOperations.refreshProjectSteering, { + capabilityId: "steering", + availableToolNames: ["writer--fetch-paper"], + }) + ); + assertEquals(selection, ["coder--fetch-paper"]); + } finally { + await runtime.close(); + await broker.shutdown(); + await broker.settled; + } + }); + it("installs, discovers, prepares, accepts, and begins execution in exact order", async () => { const f = fixture({ initialCheckpoint: true }); const broker = createManagedExecutorBroker({ maxActive: 1 }); diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index da0de77a74..1dec1f1501 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -307,13 +307,15 @@ function assertInstalledOperationGrants( throw new TypeError("Broker provider tool policy exceeds the installed model grant"); } } - const allowedTools = new Set(installation.grant.allowedToolNames); - // Trusted source capabilities carry canonical IDs, while an installed selector - // can name a tool relative to the owning agent's namespace. - for (const selector of installation.grant.allowedToolNames) { - allowedTools.add(namespaceAgentCapability(installation.grant.agentId, selector)); - } - for (const capability of input.tools.sources.values()) { + const allowedTools = installedToolNames(installation); + const allowedSources = new Set([ + ...installation.grant.hostToolFacadeIds, + ...installation.grant.remoteToolSourceIds, + ]); + for (const [sourceId, capability] of input.tools.sources) { + if (!allowedSources.has(sourceId)) { + throw new TypeError("Broker tool source exceeds the installed source grant"); + } for (const name of capability.allowedToolNames) { if (!allowedTools.has(name)) { throw new TypeError("Broker tool capability exceeds the installed tool grant"); @@ -322,6 +324,16 @@ function assertInstalledOperationGrants( } } +function installedToolNames(installation: ExecutorRuntimeInstall): Set { + const allowedTools = new Set(installation.grant.allowedToolNames); + // Trusted source capabilities carry canonical IDs, while an installed selector + // can name a tool relative to the owning agent's namespace. + for (const selector of installation.grant.allowedToolNames) { + allowedTools.add(namespaceAgentCapability(installation.grant.agentId, selector)); + } + return allowedTools; +} + function buildBrokerOperations( binding: ExecutorBinding, signal: AbortSignal, @@ -362,7 +374,7 @@ function buildBrokerOperations( projectId: execution.projectId, branchId: execution.branchId, ...input.state, - allowedToolNames: installation.grant.allowedToolNames, + allowedToolNames: [...installedToolNames(installation)], }); const combined = new Map(); for (const operations of [model, tools, persistence, state]) { From a82c52441519f61845b857a6847bdc996a3ab4a8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:07:04 +0200 Subject: [PATCH 142/194] fix(agent): preserve private artifact, tool, and replay data --- .../default-research-artifact-policy.ts | 85 ++++------ .../default-research-artifact-support.ts | 103 +++++++----- src/agent/artifacts/private-artifact-text.ts | 31 ++++ src/agent/hosted/child-artifact-support.ts | 10 +- src/agent/hosted/executor-model-bridge.ts | 3 +- src/agent/middleware/security/validator.ts | 3 +- src/agent/runtime/chat-stream-handler.ts | 36 +++-- src/agent/runtime/index.ts | 16 +- src/agent/runtime/tool-helpers.ts | 22 +-- src/agent/runtime/tool-result-continuation.ts | 19 ++- .../tool-execution-data-event-bridge.ts | 3 +- src/agent/streaming/tool-input.ts | 46 ++++-- src/chat/part-field-access.ts | 4 +- .../attachment-validation-intrinsics.test.ts | 48 ++++++ .../executor-model-stream-intrinsics.test.ts | 151 ++++++++++-------- ...rivate-event-checkpoint-intrinsics.test.ts | 79 +++++++++ .../research-artifact-intrinsics.test.ts | 72 +++++++++ ...tream-result-collection-intrinsics.test.ts | 71 ++++++++ .../tool-input-string-intrinsics.test.ts | 48 ++++++ 19 files changed, 615 insertions(+), 235 deletions(-) create mode 100644 src/agent/artifacts/private-artifact-text.ts create mode 100644 tests/integration/agent/attachment-validation-intrinsics.test.ts create mode 100644 tests/integration/agent/private-event-checkpoint-intrinsics.test.ts create mode 100644 tests/integration/agent/research-artifact-intrinsics.test.ts create mode 100644 tests/integration/agent/stream-result-collection-intrinsics.test.ts create mode 100644 tests/integration/agent/tool-input-string-intrinsics.test.ts diff --git a/src/agent/artifacts/default-research-artifact-policy.ts b/src/agent/artifacts/default-research-artifact-policy.ts index 425e0e68bf..e57c8ca70b 100644 --- a/src/agent/artifacts/default-research-artifact-policy.ts +++ b/src/agent/artifacts/default-research-artifact-policy.ts @@ -1,3 +1,5 @@ +import { privateArtifactText as text } from "./private-artifact-text.ts"; +import { joinPrivateArray } from "#veryfront/security/private-array.ts"; const RESEARCH_TASK_CUE_PATTERN = /\b(research|report|findings|sources|authoritative sources)\b/i; const RESEARCH_PROJECT_SAVE_CUE_PATTERN = /\b(?:save|write|persist|store|compile)\b[^\n]{0,120}\b(?:to|into)\b[^\n]{0,40}\b(?:the\s+)?project\b/i; @@ -5,62 +7,43 @@ const RESEARCH_PROJECT_SAVE_CUE_PATTERN = const PROJECT_ARTIFACT_PATH_PATTERN = /(?:\/|\.{1,2}\/)?(?:[\w.-]+\/)+[\w.-]+\.[\w.-]+/g; function slugifyArtifactSegment(value: string): string { - return value - .toLowerCase() - .replace(/['"]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); + const lower = text.toLowerCase(value); + const unquoted = text.replace(lower, /['"]/g, ""); + const separated = text.replace(unquoted, /[^a-z0-9]+/g, "-"); + return text.replace(separated, /^-+|-+$/g, ""); } function slugifyRunArtifactSegment(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, ""); + const separated = text.replace(text.toLowerCase(value), /[^a-z0-9_-]+/g, "-"); + return text.replace(separated, /^-+|-+$/g, ""); } function hasAnyArtifactPath(prompt: string): boolean { - PROJECT_ARTIFACT_PATH_PATTERN.lastIndex = 0; - return PROJECT_ARTIFACT_PATH_PATTERN.test(prompt); + return text.match(prompt, PROJECT_ARTIFACT_PATH_PATTERN) !== null; } function isGenericResearchTopic(value: string): boolean { - const normalized = value.trim().toLowerCase(); + const normalized = text.toLowerCase(text.trim(value)); return normalized.length === 0 || - normalized === "this" || - normalized === "that" || - normalized === "it" || - normalized === "the project" || - normalized === "the topic"; + normalized === "this" || normalized === "that" || normalized === "it" || + normalized === "the project" || normalized === "the topic"; } function extractResearchTopic(input: { description: string; prompt: string }): string | null { - const quotedPromptTopic = input.prompt.match(/\bresearch(?:\s+on|\s+about)?\s+["“]([^"”]+)["”]/i) - ?.[1]; - if (quotedPromptTopic?.trim() && !isGenericResearchTopic(quotedPromptTopic)) { - return quotedPromptTopic.trim(); - } - - const cleanedDescription = input.description - .replace(/^research\s+/i, "") - .replace(/\.\s+.*$/s, "") - .replace(/\s+and\s+(?:save|write|persist|store|compile)\b.*$/i, "") - .replace(/\s+across\b.*$/i, "") - .trim(); - if (cleanedDescription.length > 0) { - return cleanedDescription; - } - - const promptTopic = input.prompt - .match(/\bresearch(?:\s+on|\s+about)?\s+([^\n.,:]+)/i)?.[1] - ?.replace(/\s+and\s+save\b.*$/i, "") - ?.replace(/\s+and\s+write\b.*$/i, "") - ?.trim(); - if (!promptTopic || isGenericResearchTopic(promptTopic)) { - return null; - } - - return promptTopic; + const quoted = text.match(input.prompt, /\bresearch(?:\s+on|\s+about)?\s+["“]([^"”]+)["”]/i)?.[1]; + if (quoted && text.trim(quoted) && !isGenericResearchTopic(quoted)) return text.trim(quoted); + + let cleaned = text.replace(input.description, /^research\s+/i, ""); + cleaned = text.replace(cleaned, /\.\s+.*$/s, ""); + cleaned = text.replace(cleaned, /\s+and\s+(?:save|write|persist|store|compile)\b.*$/i, ""); + cleaned = text.trim(text.replace(cleaned, /\s+across\b.*$/i, "")); + if (cleaned.length > 0) return cleaned; + + let topic = text.match(input.prompt, /\bresearch(?:\s+on|\s+about)?\s+([^\n.,:]+)/i)?.[1]; + if (topic === undefined) return null; + topic = text.replace(topic, /\s+and\s+save\b.*$/i, ""); + topic = text.trim(text.replace(topic, /\s+and\s+write\b.*$/i, "")); + return !topic || isGenericResearchTopic(topic) ? null : topic; } /** Public API contract for default research artifact paths. */ @@ -79,13 +62,13 @@ export function shouldInjectDefaultResearchArtifactPath(input: { prompt: string; }): boolean { if ( - !RESEARCH_TASK_CUE_PATTERN.test(input.description) && - !RESEARCH_TASK_CUE_PATTERN.test(input.prompt) + text.match(input.description, RESEARCH_TASK_CUE_PATTERN) === null && + text.match(input.prompt, RESEARCH_TASK_CUE_PATTERN) === null ) { return false; } - if (!RESEARCH_PROJECT_SAVE_CUE_PATTERN.test(input.prompt)) { + if (text.match(input.prompt, RESEARCH_PROJECT_SAVE_CUE_PATTERN) === null) { return false; } @@ -104,14 +87,14 @@ export function buildDefaultResearchArtifactPathReminder(input: { const artifactPaths = buildDefaultResearchArtifactPaths(input); - return [ + return joinPrivateArray([ "Default research workspace (because no exact artifact path was provided):", `- Write the run-scoped report to exactly ${artifactPaths.runReportPath}.`, `- Then create or update the current topic report at exactly ${artifactPaths.currentReportPath}.`, `- Supporting artifacts can live at ${artifactPaths.findingsPath} and ${artifactPaths.sourcesPath} when useful.`, `CRITICAL: The task is incomplete until ${artifactPaths.runReportPath} and ${artifactPaths.currentReportPath} both exist with the final report content.`, "Use create_file or update_file yourself before finishing.", - ].join("\n"); + ], "\n"); } /** Builds default research artifact paths. */ @@ -140,8 +123,8 @@ export function buildDefaultResearchArtifactPathsFromCurrentReportPath(input: { currentReportPath: string; runId?: string; }): DefaultResearchArtifactPaths | null { - const currentReportPath = input.currentReportPath.replace(/^\/+/, ""); - const reportPathMatch = currentReportPath.match(/^research\/(.+)\/report\.md$/); + const currentReportPath = text.replace(input.currentReportPath, /^\/+/, ""); + const reportPathMatch = text.match(currentReportPath, /^research\/(.+)\/report\.md$/); if (!reportPathMatch?.[1]) { return null; } @@ -172,5 +155,5 @@ export function withDefaultResearchArtifactPath(input: { return input.prompt; } - return [input.prompt, "", reminder].join("\n"); + return joinPrivateArray([input.prompt, "", reminder], "\n"); } diff --git a/src/agent/artifacts/default-research-artifact-support.ts b/src/agent/artifacts/default-research-artifact-support.ts index 31c8f76d40..d6b1c3279c 100644 --- a/src/agent/artifacts/default-research-artifact-support.ts +++ b/src/agent/artifacts/default-research-artifact-support.ts @@ -1,3 +1,11 @@ +import { privateArtifactText as privateText } from "./private-artifact-text.ts"; +import { + filterPrivateArray, + flatMapPrivateArray, + joinPrivateArray, + mapPrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; import { isErroredToolExecutionResult, type RemoteToolSource } from "#veryfront/tool"; import { toChildRunToolInputRecord } from "../child-run/execution-support.ts"; @@ -34,11 +42,11 @@ function extractToolResultPath(result: unknown): string | null { return null; } - return result.path.replace(/^\/+/, ""); + return privateText.replace(result.path, /^\/+/, ""); } function isReportPath(path: string | null): path is string { - return path !== null && (path === "report.md" || path.endsWith("/report.md")); + return path !== null && (path === "report.md" || privateText.endsWith(path, "/report.md")); } function currentReportPathMatches( @@ -49,7 +57,7 @@ function currentReportPathMatches( return false; } - return artifacts.currentReportPath.replace(/^\/+/, "") === path; + return privateText.replace(artifacts.currentReportPath, /^\/+/, "") === path; } function buildDefaultArtifactsFromResultPath(input: { @@ -73,7 +81,7 @@ export function extractLatestUserText(messages: readonly unknown[]): string | nu } const content = message.content; - if (typeof content === "string" && content.trim().length > 0) { + if (typeof content === "string" && privateText.trim(content).length > 0) { return content; } @@ -81,14 +89,19 @@ export function extractLatestUserText(messages: readonly unknown[]): string | nu continue; } - const text = content - .flatMap((part) => - isRecord(part) && part.type === "text" && typeof part.text === "string" - ? [part.text.trim()] - : [] - ) - .filter((value) => value.length > 0) - .join("\n"); + const text = joinPrivateArray( + filterPrivateArray( + flatMapPrivateArray( + content, + (part) => + isRecord(part) && part.type === "text" && typeof part.text === "string" + ? [privateText.trim(part.text)] + : [], + ), + (value) => value.length > 0, + ), + "\n", + ); if (text.length > 0) { return text; @@ -99,13 +112,18 @@ export function extractLatestUserText(messages: readonly unknown[]): string | nu } function extractLatestUserDescription(text: string): string { - const withoutCommandSpan = text.replace( + const withoutCommandSpan = privateText.replace( + text, /\s*(\/[a-z0-9_-]+)\s*<\/span>/gi, "$1", ); - const withoutLeadingSlashCommand = withoutCommandSpan.replace(/^\s*\/[a-z0-9_-]+\s*/i, ""); + const withoutLeadingSlashCommand = privateText.replace( + withoutCommandSpan, + /^\s*\/[a-z0-9_-]+\s*/i, + "", + ); - return withoutLeadingSlashCommand.trim(); + return privateText.trim(withoutLeadingSlashCommand); } /** Fetch latest conversation user text helper. */ @@ -143,7 +161,7 @@ export async function fetchLatestConversationUserText(input: { const payload = await response.json(); const data = isRecord(payload) ? payload.data : undefined; const messages = Array.isArray(data) - ? data.map((message) => ({ + ? mapPrivateArray(data, (message) => ({ role: isRecord(message) ? message.role : undefined, content: isRecord(message) && Array.isArray(message.parts) ? message.parts : [], })) @@ -198,20 +216,24 @@ function appendSystemReminder( reminder: string, ): string | ChatSystemMessage[] { if (typeof instructions === "string") { - return instructions.includes(reminder) ? instructions : `${instructions}\n\n${reminder}`; + return privateText.includes(instructions, reminder) + ? instructions + : `${instructions}\n\n${reminder}`; } - if (instructions.some((message) => message.content.includes(reminder))) { + if ( + somePrivateArray(instructions, (message) => privateText.includes(message.content, reminder)) + ) { return instructions; } - return [ - ...instructions, - { - role: "system", - content: reminder, - }, - ]; + const output: ChatSystemMessage[] = []; + for (let index = 0; index < instructions.length; index++) output[index] = instructions[index]!; + output[output.length] = { + role: "system", + content: reminder, + }; + return output; } /** Apply default research artifact path helper. */ @@ -225,16 +247,18 @@ export function applyDefaultResearchArtifactPath( return toolInput; } - const path = typeof toolInput.path === "string" ? toolInput.path.replace(/^\/+/, "") : null; + const path = typeof toolInput.path === "string" + ? privateText.replace(toolInput.path, /^\/+/, "") + : null; if (!path) { return toolInput; } - const canonicalCurrentPath = defaultArtifacts.currentReportPath.replace(/^\/+/, ""); - const canonicalRunPath = defaultArtifacts.runReportPath.replace(/^\/+/, ""); - const canonicalFindingsPath = defaultArtifacts.findingsPath.replace(/^\/+/, ""); - const canonicalSourcesPath = defaultArtifacts.sourcesPath.replace(/^\/+/, ""); - const canonicalTopicRootPath = canonicalCurrentPath.replace(/\/report\.md$/, ""); + const canonicalCurrentPath = privateText.replace(defaultArtifacts.currentReportPath, /^\/+/, ""); + const canonicalRunPath = privateText.replace(defaultArtifacts.runReportPath, /^\/+/, ""); + const canonicalFindingsPath = privateText.replace(defaultArtifacts.findingsPath, /^\/+/, ""); + const canonicalSourcesPath = privateText.replace(defaultArtifacts.sourcesPath, /^\/+/, ""); + const canonicalTopicRootPath = privateText.replace(canonicalCurrentPath, /\/report\.md$/, ""); if ( path === canonicalCurrentPath || path === canonicalRunPath || path === canonicalFindingsPath || @@ -250,7 +274,7 @@ export function applyDefaultResearchArtifactPath( }; } - if (!path.endsWith("/report.md") && path !== "report.md") { + if (!privateText.endsWith(path, "/report.md") && path !== "report.md") { return toolInput; } @@ -277,7 +301,7 @@ export function shouldRetryCreateResearchArtifactAsUpdate(input: { } const path = typeof input.toolInput.path === "string" - ? input.toolInput.path.replace(/^\/+/, "") + ? privateText.replace(input.toolInput.path, /^\/+/, "") : null; const content = typeof input.toolInput.content === "string" ? input.toolInput.content : null; if (!path || !content) { @@ -285,14 +309,15 @@ export function shouldRetryCreateResearchArtifactAsUpdate(input: { } if (!defaultArtifacts) { - return path.startsWith("research/") && path.endsWith(".md"); + return privateText.startsWith(path, "research/") && privateText.endsWith(path, ".md"); } - const topicRootPath = defaultArtifacts.currentReportPath.replace(/^\/+/, "").replace( + const topicRootPath = privateText.replace( + privateText.replace(defaultArtifacts.currentReportPath, /^\/+/, ""), /\/report\.md$/, "", ); - return path === topicRootPath || path.startsWith(`${topicRootPath}/`); + return path === topicRootPath || privateText.startsWith(path, `${topicRootPath}/`); } /** Mirror default research run artifact helper. */ @@ -315,7 +340,7 @@ export async function mirrorDefaultResearchRunArtifact(input: { const content = typeof input.toolInput.content === "string" ? input.toolInput.content : null; const path = typeof input.toolInput.path === "string" - ? input.toolInput.path.replace(/^\/+/, "") + ? privateText.replace(input.toolInput.path, /^\/+/, "") : null; const resultPath = extractToolResultPath(input.toolResult); const contextArtifacts = input.taskContext.defaultResearchArtifacts; @@ -331,8 +356,8 @@ export async function mirrorDefaultResearchRunArtifact(input: { return; } - const canonicalCurrentPath = defaultArtifacts.currentReportPath.replace(/^\/+/, ""); - const canonicalRunPath = defaultArtifacts.runReportPath.replace(/^\/+/, ""); + const canonicalCurrentPath = privateText.replace(defaultArtifacts.currentReportPath, /^\/+/, ""); + const canonicalRunPath = privateText.replace(defaultArtifacts.runReportPath, /^\/+/, ""); if (!content || (path !== canonicalCurrentPath && resultPath !== canonicalCurrentPath)) { return; diff --git a/src/agent/artifacts/private-artifact-text.ts b/src/agent/artifacts/private-artifact-text.ts new file mode 100644 index 0000000000..ee5d55f945 --- /dev/null +++ b/src/agent/artifacts/private-artifact-text.ts @@ -0,0 +1,31 @@ +import { + privateTextEndsWith, + privateTextIncludes, + privateTextStartsWith, +} from "#veryfront/security/private-text.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const apply = Reflect.apply; +const regexpExec = RegExp.prototype.exec; +const regexpReplace = RegExp.prototype[Symbol.replace]; +const trim = String.prototype.trim; +const toLowerCase = String.prototype.toLowerCase; + +/** Captured operations for framework-owned artifact prompts and paths. */ +export const privateArtifactText = Object.freeze({ + trim: (value: string): string => apply(trim, value, []), + toLowerCase: (value: string): string => apply(toLowerCase, value, []), + startsWith: privateTextStartsWith, + endsWith: privateTextEndsWith, + includes: privateTextIncludes, + match(value: string, pattern: RegExp): RegExpExecArray | null { + pattern.lastIndex = 0; + return apply(regexpExec, pattern, [value]); + }, + replace(value: string, pattern: RegExp, replacement: string): string { + // Every caller owns its pattern. Pin exec so the captured replacement + // intrinsic cannot redispatch private input through the mutable prototype. + defineOwnDataProperty(pattern, "exec", regexpExec); + return apply(regexpReplace, pattern, [value, replacement]); + }, +}); diff --git a/src/agent/hosted/child-artifact-support.ts b/src/agent/hosted/child-artifact-support.ts index 6e72558b85..79a8541903 100644 --- a/src/agent/hosted/child-artifact-support.ts +++ b/src/agent/hosted/child-artifact-support.ts @@ -1,3 +1,5 @@ +import { privateArtifactText } from "../artifacts/private-artifact-text.ts"; +import { somePrivateArray } from "#veryfront/security/private-array.ts"; import type { ToolExecutionContext } from "#veryfront/tool"; import { toChildRunToolInputRecord } from "../child-run/execution-support.ts"; @@ -164,7 +166,8 @@ function isHostedChildCreateFileAlreadyExistsResultAtDepth( } if ( - typeof result.message === "string" && CREATE_FILE_ALREADY_EXISTS_PATTERN.test(result.message) + typeof result.message === "string" && + privateArtifactText.match(result.message, CREATE_FILE_ALREADY_EXISTS_PATTERN) !== null ) { return true; } @@ -184,12 +187,13 @@ function hasAlreadyExistsContentPart(content: unknown): boolean { return false; } - return content.some((part) => { + return somePrivateArray(content, (part) => { if (!isRecord(part)) { return false; } - return typeof part.text === "string" && CREATE_FILE_ALREADY_EXISTS_PATTERN.test(part.text); + return typeof part.text === "string" && + privateArtifactText.match(part.text, CREATE_FILE_ALREADY_EXISTS_PATTERN) !== null; }); } diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index 2e2a9f1635..b8b1c37777 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -1,3 +1,4 @@ +const hasOwn = Object.hasOwn; import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import { createPrivateReadableStream } from "#veryfront/security/private-stream.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; @@ -301,7 +302,7 @@ function throwProviderStreamError(value: unknown, rawEnvelope = false): void { function rejectReceivedStreamError(value: JsonValue, rawEnvelope = false): void { if (value === null || typeof value !== "object" || Array.isArray(value)) return; if (value.type === "tool-error" && !rawEnvelope) return; - if (value.type === "error" || Object.hasOwn(value, "error")) { + if (value.type === "error" || hasOwn(value, "error")) { throw new TypeError("Invalid managed model stream chunk"); } if (value.type === "raw" && value.rawValue !== undefined) { diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 9009c88b47..836f6a6ea9 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,3 +1,4 @@ +import { privateTextTrimStart } from "#veryfront/security/private-text.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { @@ -628,7 +629,7 @@ function extractAdjacentRuns( function messageTextParts(message: Message, includeAttachments = true): string[] { const texts = mapPrivateArray(filterPrivateArray(message.parts, isTextPart), (part) => part.text); const attachmentContext = includeAttachments && message.role === "user" - ? buildAttachmentContextFromParts(message.parts).trimStart() + ? privateTextTrimStart(buildAttachmentContextFromParts(message.parts)) : ""; if (attachmentContext) texts.push(attachmentContext); return texts; diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 76808732a3..39f917a83d 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,5 +1,6 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; -import { pushPrivateArray } from "#veryfront/security/private-array.ts"; +import { pushPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { closePrivateStream, @@ -618,16 +619,21 @@ function readTraceAttributeString( return typeof value === "string" ? value : undefined; } +function finalToolResultIds(state: ChatStreamState): Set { + const ids = createPrivateSet(); + for (let index = 0; index < state.toolResults.length; index++) { + const result = state.toolResults[index]!; + if (result.preliminary !== true) ids.add(result.toolCallId); + } + return ids; +} + function finalizeActiveUnresolvedProviderToolCalls( state: ChatStreamState, controller: ReadableStreamDefaultController, encoder: TextEncoder, ): void { - const terminalToolCallIds = new Set( - state.toolResults - .filter((result) => result.preliminary !== true) - .map((result) => result.toolCallId), - ); + const terminalToolCallIds = finalToolResultIds(state); for (const toolCall of state.toolCalls.values()) { if ( @@ -901,11 +907,7 @@ export function processStreamInternal( // Ignore any preliminary entries carried in from an older stream state. // They are progress, not proof that the provider answered. - const terminalToolCallIds = new Set( - state.toolResults - .filter((result) => result.preliminary !== true) - .map((result) => result.toolCallId), - ); + const terminalToolCallIds = finalToolResultIds(state); for (const toolCall of state.toolCalls.values()) { if (!pendingProviderExecutedToolCallIds.has(toolCall.id)) continue; @@ -1465,8 +1467,10 @@ export function processStreamInternal( ); if ( typedPart.preliminary !== true && providerExecuted === true && - state.toolResults.some((result) => - result.toolCallId === typedPart.toolCallId && result.preliminary !== true + somePrivateArray( + state.toolResults, + (result) => + result.toolCallId === typedPart.toolCallId && result.preliminary !== true, ) ) { break; @@ -1557,8 +1561,10 @@ export function processStreamInternal( ); if ( providerExecuted === true && - state.toolResults.some((result) => - result.toolCallId === typedPart.toolCallId && result.preliminary !== true + somePrivateArray( + state.toolResults, + (result) => + result.toolCallId === typedPart.toolCallId && result.preliminary !== true, ) ) { break; diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 95d95786e7..535aefec55 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1186,11 +1186,17 @@ function resolveRuntimeProviderReplayCheckpointEmission( config: AgentConfig, ): RuntimeProviderReplayCheckpointEmission { const messageId = getRuntimeProviderReplayCheckpointMessageId(config); - const existingCheckpoint = messageId - ? getRuntimeProviderReplayCheckpoints(config)?.find((checkpoint) => - checkpoint.messageId === messageId - ) - : undefined; + const checkpoints = getRuntimeProviderReplayCheckpoints(config); + let existingCheckpoint: ProviderReplayCheckpoint | undefined; + if (messageId && checkpoints) { + for (let index = 0; index < checkpoints.length; index++) { + const checkpoint = checkpoints[index]; + if (checkpoint?.messageId === messageId) { + existingCheckpoint = checkpoint; + break; + } + } + } return { state: messageId ? createProviderReplayCheckpointEmissionState({ messageId, existingCheckpoint }) diff --git a/src/agent/runtime/tool-helpers.ts b/src/agent/runtime/tool-helpers.ts index b9a19f4199..2e0ba456c6 100644 --- a/src/agent/runtime/tool-helpers.ts +++ b/src/agent/runtime/tool-helpers.ts @@ -1,3 +1,4 @@ +import { stripLeadingEmptyObjectPlaceholder } from "../streaming/tool-input.ts"; /** * Tool Helpers * @@ -44,27 +45,6 @@ export interface ParsedToolArgs { error?: string; } -function stripLeadingEmptyObjectPlaceholder(rawArgs: string): string { - let normalized = rawArgs.trim(); - - while (normalized.startsWith("{}")) { - const remainder = normalized.slice(2).trimStart(); - if (remainder.startsWith("{")) { - normalized = remainder; - continue; - } - - if (remainder.startsWith('"')) { - normalized = `{${remainder}`; - continue; - } - - break; - } - - return normalized; -} - /** * Parse tool arguments from raw string or object. * Returns parsed args and optional error message. diff --git a/src/agent/runtime/tool-result-continuation.ts b/src/agent/runtime/tool-result-continuation.ts index 1c249f3df5..54113e4d66 100644 --- a/src/agent/runtime/tool-result-continuation.ts +++ b/src/agent/runtime/tool-result-continuation.ts @@ -1,3 +1,4 @@ +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { pushPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; import { type Message, type MessagePart, type ToolResultPart } from "../types.ts"; import { stripLeadingEmptyObjectPlaceholder } from "../streaming/data-stream.ts"; @@ -75,9 +76,10 @@ export function getProviderExecutedToolNames(runtimeTools: RuntimeToolSet | unde export function collectFinalStreamToolResults( state: Pick, ): Map { - const finalToolResults = new Map(); + const finalToolResults = createPrivateMap(); - for (const toolResult of state.toolResults) { + for (let index = 0; index < state.toolResults.length; index++) { + const toolResult = state.toolResults[index]!; if (toolResult.preliminary === true) { continue; } @@ -91,14 +93,16 @@ export function collectFinalStreamToolResults( export function collectPersistedToolResults( messages: Message[], ): Map { - const persistedToolResults = new Map(); + const persistedToolResults = createPrivateMap(); - for (const message of messages) { + for (let index = 0; index < messages.length; index++) { + const message = messages[index]!; if (message.role !== "tool") { continue; } - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + const part = message.parts[partIndex]!; if (!isToolResultPart(part)) { continue; } @@ -113,9 +117,10 @@ export function collectPersistedToolResults( export function collectGeneratedToolResults( toolResults: RuntimeGenerateToolResult[] | undefined, ): Map { - const generatedToolResults = new Map(); + const generatedToolResults = createPrivateMap(); - for (const toolResult of toolResults ?? []) { + for (let index = 0; index < (toolResults?.length ?? 0); index++) { + const toolResult = toolResults![index]!; generatedToolResults.set(toolResult.toolCallId, toolResult); } diff --git a/src/agent/streaming/tool-execution-data-event-bridge.ts b/src/agent/streaming/tool-execution-data-event-bridge.ts index 24e9978d97..440de7133b 100644 --- a/src/agent/streaming/tool-execution-data-event-bridge.ts +++ b/src/agent/streaming/tool-execution-data-event-bridge.ts @@ -1,3 +1,4 @@ +const hasOwn = Object.hasOwn; import { toPrivateUint8Array } from "#veryfront/security/private-bytes.ts"; import { encodePrivateText } from "#veryfront/security/private-text.ts"; import { @@ -22,7 +23,7 @@ export type ToolExecutionDataEventBridgeStreamInput = { function serializeToolExecutionDataEvent(event: ToolExecutionDataEvent): Uint8Array { if (typeof event.name === "string" && event.name.length > 0) { - const data = Object.hasOwn(event, "value") ? event.value : event.data; + const data = hasOwn(event, "value") ? event.value : event.data; return encodePrivateText( `data: ${privateJsonStringify({ type: `data-${event.name}`, data })}\n\n`, ); diff --git a/src/agent/streaming/tool-input.ts b/src/agent/streaming/tool-input.ts index 3ae914dd98..ef9a86be7a 100644 --- a/src/agent/streaming/tool-input.ts +++ b/src/agent/streaming/tool-input.ts @@ -1,7 +1,17 @@ +import { + privateTextEndsWith as endsWith, + privateTextSlice as slice, + privateTextStartsWith as startsWith, + privateTextTrimStart as trimStart, +} from "#veryfront/security/private-text.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; const logger = serverLogger.component("agent-tool-input"); +const apply = Reflect.apply; +const stringTrim = String.prototype.trim; +const min = Math.min; +const trim = (value: string): string => apply(stringTrim, value, []); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -9,16 +19,16 @@ function isRecord(value: unknown): value is Record { /** Normalize provider tool input by removing transient empty-object prefixes. */ export function stripLeadingEmptyObjectPlaceholder(rawArgs: string): string { - let normalized = rawArgs.trim(); + let normalized = trim(rawArgs); - while (normalized.startsWith("{}")) { - const remainder = normalized.slice(2).trimStart(); - if (remainder.startsWith("{")) { + while (startsWith(normalized, "{}")) { + const remainder = trimStart(slice(normalized, 2)); + if (startsWith(remainder, "{")) { normalized = remainder; continue; } - if (remainder.startsWith('"')) { + if (startsWith(remainder, '"')) { normalized = `{${remainder}`; continue; } @@ -53,14 +63,15 @@ const MIN_OVERLAP_DEDUP_LENGTH = 4; /** Merge tool input delta helper. */ export function mergeToolInputDelta(currentArguments: string, nextDelta: string): string { - const normalizedDelta = nextDelta.trimStart(); - const candidateDeltas = normalizedDelta.startsWith('"') + const normalizedDelta = trimStart(nextDelta); + const candidateDeltas = startsWith(normalizedDelta, '"') ? [normalizedDelta, `{${normalizedDelta}`] : [normalizedDelta]; if (currentArguments === "{}" || currentArguments.length === 0) { - for (const candidate of candidateDeltas) { - if (candidate.startsWith("{")) { + for (let index = 0; index < candidateDeltas.length; index++) { + const candidate = candidateDeltas[index]!; + if (startsWith(candidate, "{")) { return candidate; } } @@ -81,7 +92,8 @@ export function mergeToolInputDelta(currentArguments: string, nextDelta: string) return currentArguments + nextDelta; } - for (const candidate of candidateDeltas) { + for (let index = 0; index < candidateDeltas.length; index++) { + const candidate = candidateDeltas[index]!; // Exact duplicate: the provider resent the same full buffer. if (candidate === currentArguments) { return currentArguments; @@ -89,7 +101,7 @@ export function mergeToolInputDelta(currentArguments: string, nextDelta: string) // Cumulative mode: the delta is a strict extension of the current // buffer and supersedes it verbatim. - if (candidate.startsWith(currentArguments)) { + if (startsWith(candidate, currentArguments)) { return candidate; } @@ -98,10 +110,10 @@ export function mergeToolInputDelta(currentArguments: string, nextDelta: string) // MIN_OVERLAP_DEDUP_LENGTH or longer. Trivial 1-3 char matches in // streamed JSON are overwhelmingly coincidental and deduping them // corrupts append-mode streams. - const maxOverlap = Math.min(currentArguments.length, candidate.length); + const maxOverlap = min(currentArguments.length, candidate.length); for (let overlap = maxOverlap; overlap >= MIN_OVERLAP_DEDUP_LENGTH; overlap--) { - if (currentArguments.endsWith(candidate.slice(0, overlap))) { - return currentArguments + candidate.slice(overlap); + if (endsWith(currentArguments, slice(candidate, 0, overlap))) { + return currentArguments + slice(candidate, overlap); } } } @@ -117,15 +129,15 @@ export function mergeToolCallInput(currentArguments: string, nextInput: string): const normalizedCurrent = stripLeadingEmptyObjectPlaceholder(currentArguments); - if (nextInput.trim() === "{}" && currentArguments.trim().startsWith("{")) { + if (trim(nextInput) === "{}" && startsWith(trim(currentArguments), "{")) { return currentArguments; } - if (nextInput.trim() === "{}" && normalizedCurrent.trim().startsWith("{")) { + if (trim(nextInput) === "{}" && startsWith(trim(normalizedCurrent), "{")) { return normalizedCurrent; } - if (currentArguments.trim() === "{}" && nextInput.trim().startsWith("{")) { + if (trim(currentArguments) === "{}" && startsWith(trim(nextInput), "{")) { return nextInput; } diff --git a/src/chat/part-field-access.ts b/src/chat/part-field-access.ts index f8a7ba7cec..7b505ab029 100644 --- a/src/chat/part-field-access.ts +++ b/src/chat/part-field-access.ts @@ -28,9 +28,7 @@ export function getStringField(value: unknown, field: string, fallback: string): /** Return a string field when present, else undefined. */ export function getOptionalStringField(value: unknown, key: string): string | undefined { - if (!isRecord(value)) { - return undefined; - } + if (!isRecord(value)) return undefined; const field = value[key]; return typeof field === "string" ? field : undefined; diff --git a/tests/integration/agent/attachment-validation-intrinsics.test.ts b/tests/integration/agent/attachment-validation-intrinsics.test.ts new file mode 100644 index 0000000000..c44d2ba2a5 --- /dev/null +++ b/tests/integration/agent/attachment-validation-intrinsics.test.ts @@ -0,0 +1,48 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; +import type { AgentContext } from "#veryfront/agent/types.ts"; + +describe("private attachment validation", () => { + it("keeps filenames and upload paths out of replaced trimStart", async () => { + const marker = "synthetic-private-attachment-validation"; + const context: AgentContext = { + agentId: "synthetic", + model: "hosted/synthetic", + data: {}, + platform: {}, + input: [{ + id: "user", + role: "user", + parts: [{ + type: "file", + filename: `${marker}.txt`, + mediaType: "text/plain", + url: `https://example.test/${marker}`, + uploadPath: `uploads/${marker}`, + }], + }], + }; + const middleware = securityMiddleware({ + input: { blockedPatterns: [/synthetic-never-matches/] }, + }); + const original = String.prototype.trimStart; + let observations = 0; + let calls = 0; + try { + String.prototype.trimStart = function () { + if (this.includes(marker)) observations++; + return Reflect.apply(original, this, []); + }; + await middleware(context, () => { + calls++; + return Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }); + }); + } finally { + String.prototype.trimStart = original; + } + assertEquals(calls, 1); + assertEquals(observations, 0); + }); +}); diff --git a/tests/integration/agent/executor-model-stream-intrinsics.test.ts b/tests/integration/agent/executor-model-stream-intrinsics.test.ts index 5f0a9143ec..68c497aa71 100644 --- a/tests/integration/agent/executor-model-stream-intrinsics.test.ts +++ b/tests/integration/agent/executor-model-stream-intrinsics.test.ts @@ -8,80 +8,89 @@ import { } from "#veryfront/agent/hosted/executor-model-bridge.ts"; describe("managed model private stream construction", () => { - it("keeps model output out of a replaced stream constructor", async () => { - const marker = "synthetic-private-managed-model-output"; - const modelId = "veryfront-cloud/openai/synthetic-model"; - const NativeReadableStream = ReadableStream; - const source = new NativeReadableStream({ - start(controller) { - controller.enqueue({ type: "text-delta", delta: marker }); - controller.close(); - }, - }); - const binding = { allocationId: "model-stream", invocationId: "model-stream", generation: 1 }; - const forward = new TransformStream(); - const backward = new TransformStream(); - const allowedModelIds = new Set([modelId]); - const broker = createExecutorChannel({ - binding, - transport: { readable: forward.readable, writable: backward.writable }, - operations: createExecutorModelBroker({ - allowedModelIds, - resolveModelRuntime: () => ({ - specificationVersion: "v3", - modelId: "synthetic-model", - provider: "openai", - doGenerate: () => Promise.reject(new Error("Unexpected generation")), - doStream: () => Promise.resolve({ stream: source }), + for (const probe of ["stream constructor", "own-property check"]) { + it(`keeps model output out of a replaced ${probe}`, async () => { + const marker = "synthetic-private-managed-model-output"; + const modelId = "veryfront-cloud/openai/synthetic-model"; + const NativeReadableStream = ReadableStream; + const hasOwn = Object.hasOwn; + const source = new NativeReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: marker }); + controller.close(); + }, + }); + const binding = { allocationId: "model-stream", invocationId: "model-stream", generation: 1 }; + const forward = new TransformStream(); + const backward = new TransformStream(); + const allowedModelIds = new Set([modelId]); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + specificationVersion: "v3", + modelId: "synthetic-model", + provider: "openai", + doGenerate: () => Promise.reject(new Error("Unexpected generation")), + doStream: () => Promise.resolve({ stream: source }), + }), }), - }), - }); - const executor = createExecutorChannel({ - binding, - transport: { readable: backward.readable, writable: forward.writable }, - }); - let observations = 0; - let chunks: unknown[] = []; - try { - const resolver = await createExecutorModelRuntimeResolver({ - channel: executor, - allowedModelIds, }); - globalThis.ReadableStream = new Proxy(NativeReadableStream, { - construct(target, args) { - const underlying = args[0] as UnderlyingDefaultSource; - const pull = underlying.pull; - const wrapped = { - ...underlying, - pull(controller: ReadableStreamDefaultController) { - const facade = { - enqueue(chunk: unknown) { - if (JSON.stringify(chunk)?.includes(marker)) observations++; - controller.enqueue(chunk); - }, - close: () => controller.close(), - error: (error: unknown) => controller.error(error), - get desiredSize() { - return controller.desiredSize; + const executor = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + let observations = 0; + let chunks: unknown[] = []; + try { + const resolver = await createExecutorModelRuntimeResolver({ + channel: executor, + allowedModelIds, + }); + if (probe === "own-property check") { + Object.hasOwn = (value, property) => { + if ((value as { delta?: unknown })?.delta === marker) observations++; + return hasOwn(value, property); + }; + } else {globalThis.ReadableStream = new Proxy(NativeReadableStream, { + construct(target, args) { + const underlying = args[0] as UnderlyingDefaultSource; + const pull = underlying.pull; + const wrapped = { + ...underlying, + pull(controller: ReadableStreamDefaultController) { + const facade = { + enqueue(chunk: unknown) { + if (JSON.stringify(chunk)?.includes(marker)) observations++; + controller.enqueue(chunk); + }, + close: () => controller.close(), + error: (error: unknown) => controller.error(error), + get desiredSize() { + return controller.desiredSize; + }, + }; + return pull === undefined ? undefined : Reflect.apply(pull, underlying, [facade]); }, }; - return pull === undefined ? undefined : Reflect.apply(pull, underlying, [facade]); + return Reflect.construct(target, [wrapped, args[1]]); }, - }; - return Reflect.construct(target, [wrapped, args[1]]); - }, - }); - const result = await resolver(modelId)!.doStream({ - prompt: [{ role: "user", content: [{ type: "text", text: "Synthetic input" }] }], - }); - chunks = await Array.fromAsync(result.stream); - } finally { - globalThis.ReadableStream = NativeReadableStream; - executor.close(); - broker.close(); - await Promise.all([executor.settled, broker.settled]); - } - assertEquals(chunks, [{ type: "text-delta", delta: marker }]); - assertEquals(observations, 0); - }); + });} + const result = await resolver(modelId)!.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Synthetic input" }] }], + }); + chunks = await Array.fromAsync(result.stream); + } finally { + globalThis.ReadableStream = NativeReadableStream; + Object.hasOwn = hasOwn; + executor.close(); + broker.close(); + await Promise.all([executor.settled, broker.settled]); + } + assertEquals(chunks, [{ type: "text-delta", delta: marker }]); + assertEquals(observations, 0); + }); + } }); diff --git a/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts b/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts new file mode 100644 index 0000000000..9d7c8d60a5 --- /dev/null +++ b/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts @@ -0,0 +1,79 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createToolExecutionDataEventBridgeStream, + type ToolExecutionDataEventPublisher, +} from "#veryfront/agent/streaming/tool-execution-data-event-bridge.ts"; +import { AgentRuntime } from "#veryfront/agent/runtime/index.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import type { RuntimeToolFilterConfig } from "#veryfront/agent/runtime/runtime-tool-config.ts"; + +describe("private events and replay checkpoints", () => { + it("does not pass named tool data events to a replaced own-property check", async () => { + const marker = "synthetic-private-tool-event"; + let publish: ToolExecutionDataEventPublisher | undefined; + let close: (() => void) | undefined; + const stream = createToolExecutionDataEventBridgeStream({ + baseStream: new ReadableStream({ + start(controller) { + close = () => controller.close(); + }, + }), + installPublisher: (publisher) => { + publish = publisher; + }, + }); + const hasOwn = Object.hasOwn; + let observations = 0; + try { + Object.hasOwn = (value, property) => { + if ((value as { value?: unknown })?.value === marker) observations++; + return hasOwn(value, property); + }; + publish!({ type: "data", name: "synthetic", value: marker }); + } finally { + Object.hasOwn = hasOwn; + close!(); + } + assertStringIncludes(await new Response(stream).text(), marker); + assertEquals(observations, 0); + }); + + it("does not expose initial replay checkpoints to a replaced array find", async () => { + const checkpoints: NonNullable = [{ + version: 1, + messageId: "assistant-synthetic", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "text", text: "synthetic-private-replay" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }]; + const config: RuntimeToolFilterConfig = { + model: "veryfront-cloud/anthropic/synthetic", + system: "Synthetic instructions", + __vfProviderReplayCheckpoints: checkpoints, + __vfProviderReplayCheckpointMessageId: "assistant-synthetic", + }; + const model = scriptedModel([{ text: "Complete" }], { only: "generate", provider: "anthropic" }); + const runtime = new AgentRuntime("synthetic", config, { resolveModelRuntime: () => model }); + const find = Array.prototype.find; + let observations = 0; + let result; + try { + Array.prototype.find = function (...args: unknown[]) { + if (this === checkpoints) observations++; + return Reflect.apply(find, this, args); + }; + result = await runtime.generate("Synthetic request"); + } finally { + Array.prototype.find = find; + } + assertEquals(result?.text, "Complete"); + assertEquals(observations, 0); + }); +}); diff --git a/tests/integration/agent/research-artifact-intrinsics.test.ts b/tests/integration/agent/research-artifact-intrinsics.test.ts new file mode 100644 index 0000000000..ed39feccde --- /dev/null +++ b/tests/integration/agent/research-artifact-intrinsics.test.ts @@ -0,0 +1,72 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + applyDefaultResearchArtifactPath, + type DefaultResearchArtifactContext, + shouldRetryCreateResearchArtifactAsUpdate, + updateDefaultResearchArtifacts, +} from "#veryfront/agent/artifacts/default-research-artifact-support.ts"; + +describe("private research artifact inputs", () => { + for ( + const [prototype, method] of [ + [String.prototype, "replace"], + [String.prototype, "match"], + [String.prototype, "trim"], + [String.prototype, "toLowerCase"], + [String.prototype, "startsWith"], + [String.prototype, "endsWith"], + [RegExp.prototype, "test"], + [RegExp.prototype, "exec"], + [RegExp.prototype, Symbol.replace], + ] as const + ) { + it(`keeps conversation text and tool paths out of replaced ${String(method)}`, () => { + const marker = "synthetic-private-research"; + const prompt = `/research ${marker} and save findings to the project`; + const context: DefaultResearchArtifactContext = { parentRunId: "synthetic-run" }; + const descriptor = Object.getOwnPropertyDescriptor(prototype, method)!; + const apply = Reflect.apply; + const includes = String.prototype.includes; + let observations = 0; + let system; + let toolInput; + let retry; + try { + Object.defineProperty(prototype, method, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (typeof this === "string" && apply(includes, this, [marker])) observations++; + if (typeof args[0] === "string" && apply(includes, args[0], [marker])) observations++; + return apply(descriptor.value, this, args); + }, + }); + system = updateDefaultResearchArtifacts({ + taskContext: context, + latestUserText: prompt, + system: "Base instructions", + }); + toolInput = applyDefaultResearchArtifactPath("create_file", { + path: `/${marker}/report.md`, + content: "Synthetic report", + }, context); + retry = shouldRetryCreateResearchArtifactAsUpdate({ + toolName: "create_file", + toolInput: { + path: context.defaultResearchArtifacts!.currentReportPath, + content: "Synthetic report", + }, + taskContext: context, + error: { isError: true, content: [{ type: "text", text: `File already exists: ${marker}.md` }] }, + }); + } finally { + Object.defineProperty(prototype, method, descriptor); + } + assertStringIncludes(String(system), `/research/${marker}/report.md`); + assertEquals(toolInput?.path, `research/${marker}/report.md`); + assertEquals(retry, true); + assertEquals(observations, 0); + }); + } +}); diff --git a/tests/integration/agent/stream-result-collection-intrinsics.test.ts b/tests/integration/agent/stream-result-collection-intrinsics.test.ts new file mode 100644 index 0000000000..e3ae01bca1 --- /dev/null +++ b/tests/integration/agent/stream-result-collection-intrinsics.test.ts @@ -0,0 +1,71 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createMockResult, + createSSECollector, +} from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; +import { createStreamState, processStream } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { collectFinalStreamToolResults } from "#veryfront/agent/runtime/tool-result-continuation.ts"; + +describe("private completed stream results", () => { + for (const method of [Symbol.iterator, "filter", "map", "some"] as const) { + it(`does not expose result collections through ${String(method)}`, async () => { + const marker = "synthetic-private-provider-result"; + const { controller, encoder } = createSSECollector(); + const state = createStreamState(); + const result = createMockResult([ + { + type: "tool-call", + toolCallId: "synthetic-call", + toolName: "inspect", + input: {}, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "synthetic-call", + toolName: "inspect", + output: { text: marker }, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "synthetic-call", + toolName: "inspect", + output: { text: marker }, + providerExecuted: true, + }, + { + type: "tool-call", + toolCallId: "pending-call", + toolName: "inspect", + input: {}, + providerExecuted: true, + }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]); + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, method)!; + const apply = Reflect.apply; + let observations = 0; + let collected; + try { + Object.defineProperty(Array.prototype, method, { + ...descriptor, + value: function (this: unknown[], ...args: unknown[]) { + if ( + this.length > 0 && state.toolResults.length > 0 && this[0] === state.toolResults[0] + ) observations++; + return apply(descriptor.value, this, args); + }, + }); + await processStream(result, state, controller, encoder, "synthetic-text", undefined); + collected = collectFinalStreamToolResults(state); + } finally { + Object.defineProperty(Array.prototype, method, descriptor); + } + assertEquals(collected.get("synthetic-call")?.output, { text: marker }); + assertEquals(observations, 0); + }); + } +}); diff --git a/tests/integration/agent/tool-input-string-intrinsics.test.ts b/tests/integration/agent/tool-input-string-intrinsics.test.ts new file mode 100644 index 0000000000..82d77f1b51 --- /dev/null +++ b/tests/integration/agent/tool-input-string-intrinsics.test.ts @@ -0,0 +1,48 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + mergeToolCallInput, + mergeToolInputDelta, + stripLeadingEmptyObjectPlaceholder, +} from "#veryfront/agent/streaming/tool-input.ts"; +import { parseToolArgs } from "#veryfront/agent/runtime/tool-helpers.ts"; + +describe("private streamed tool input normalization", () => { + for (const method of ["trim", "trimStart", "startsWith", "endsWith", "slice"] as const) { + it(`does not expose arguments through replaced ${method}`, () => { + const marker = "synthetic-private-stream-input"; + const payload = JSON.stringify({ text: marker }); + const original = String.prototype[method]; + const apply = Reflect.apply; + const includes = String.prototype.includes; + let observations = 0; + let normalized, parsed, merged, complete; + try { + Object.defineProperty(String.prototype, method, { + configurable: true, + writable: true, + value: function (this: string, ...args: unknown[]) { + if (apply(includes, this, [marker])) observations++; + return apply(original, this, args); + }, + }); + normalized = stripLeadingEmptyObjectPlaceholder(` {} ${payload} `); + parsed = parseToolArgs(` {} ${payload}`); + merged = mergeToolInputDelta(payload, "-suffix"); + complete = mergeToolCallInput(payload, "{}"); + } finally { + Object.defineProperty(String.prototype, method, { + configurable: true, + writable: true, + value: original, + }); + } + assertEquals(normalized, payload); + assertEquals(parsed?.args, { text: marker }); + assertEquals(merged, payload + "-suffix"); + assertEquals(complete, payload); + assertEquals(observations, 0); + }); + } +}); From be3d48343f606828218eecfb005a549a765d66aa Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:07:37 +0200 Subject: [PATCH 143/194] style(test): format private-data regressions --- .../agent/private-event-checkpoint-intrinsics.test.ts | 5 ++++- tests/integration/agent/research-artifact-intrinsics.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts b/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts index 9d7c8d60a5..84615546d8 100644 --- a/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts +++ b/tests/integration/agent/private-event-checkpoint-intrinsics.test.ts @@ -59,7 +59,10 @@ describe("private events and replay checkpoints", () => { __vfProviderReplayCheckpoints: checkpoints, __vfProviderReplayCheckpointMessageId: "assistant-synthetic", }; - const model = scriptedModel([{ text: "Complete" }], { only: "generate", provider: "anthropic" }); + const model = scriptedModel([{ text: "Complete" }], { + only: "generate", + provider: "anthropic", + }); const runtime = new AgentRuntime("synthetic", config, { resolveModelRuntime: () => model }); const find = Array.prototype.find; let observations = 0; diff --git a/tests/integration/agent/research-artifact-intrinsics.test.ts b/tests/integration/agent/research-artifact-intrinsics.test.ts index ed39feccde..ab0d987e80 100644 --- a/tests/integration/agent/research-artifact-intrinsics.test.ts +++ b/tests/integration/agent/research-artifact-intrinsics.test.ts @@ -58,7 +58,10 @@ describe("private research artifact inputs", () => { content: "Synthetic report", }, taskContext: context, - error: { isError: true, content: [{ type: "text", text: `File already exists: ${marker}.md` }] }, + error: { + isError: true, + content: [{ type: "text", text: `File already exists: ${marker}.md` }], + }, }); } finally { Object.defineProperty(prototype, method, descriptor); From c3339b2686319ab57ed2815f771f4bcf221556e3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:15:24 +0200 Subject: [PATCH 144/194] fix(agent): prepare against effective broker model grants --- docs/guides/agent-service-runtime.md | 5 ++- .../hosted/managed-executor-broker.test.ts | 37 +++++++++++++++---- src/agent/hosted/managed-executor-broker.ts | 7 +++- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 343f8bb884..ebd8c4f42f 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -487,4 +487,7 @@ service list after the first heartbeat Broker model output limits and provider-tool descriptors must stay within the installed model grant. Each broker tool capability must also stay within the installed tool allowlist. Startup rejects broader -broker authority before allocating an executor. Narrower broker limits remain valid. +broker authority before allocating an executor. Preparation uses the narrower broker model output +limits and provider-tool list, so its default model requests fit the broker policy. Source IDs must +belong to the installed host-facade or remote-source grants. Owner-scoped tool selectors use the same +canonical names for capability checks and steering refreshes. diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index ecb9af7647..f25fec7a41 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -1,3 +1,8 @@ +import { + type ExecutorRuntimeInstall, + getExecutorRuntimeInstallSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; @@ -42,6 +47,7 @@ function runtimeModel(): ModelRuntime { function fixture( options: { + completeStream?: boolean; agentId?: string; prepareFailure?: boolean; prepareModelId?: string; @@ -66,6 +72,7 @@ function fixture( let generation = 0; let preparationDenied = false; let executionAllowed = false; + let installed: ExecutorRuntimeInstall | undefined; let initialCheckpointRead = false; const preparation = new AbortController(); const prepareEntered = Promise.withResolvers(); @@ -135,7 +142,8 @@ function fixture( const operations = new Map([ ["runtime.install", { mode: "unary", - async handle() { + async handle(value) { + installed = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), value); calls.push("install"); try { await peer!.request("model.generate", { modelId, options: { prompt: [] } }); @@ -195,14 +203,19 @@ function fixture( }], ["agent.stream", { mode: "stream", - async *handle() { + async *handle(): AsyncGenerator { calls.push("stream"); await peer!.request("model.generate", { modelId, - options: { prompt: [], maxOutputTokens: 100 }, + options: { prompt: [], maxOutputTokens: installed!.grant.models[0]!.maxOutputTokens }, }); executionAllowed = true; yield { type: "ready" }; + if (options.completeStream) { + yield { type: "event", event: { type: "message-finish" } }; + yield { type: "complete" }; + return; + } await new Promise(() => {}); }, }], @@ -277,6 +290,9 @@ function fixture( preparation, prepareEntered: prepareEntered.promise, releasePrepare: prepareRelease.resolve, + get installed() { + return installed; + }, get peer() { return peer; }, @@ -363,10 +379,10 @@ describe("managed executor broker", () => { }); } - it("accepts narrower model limits and matching provider and tool grants", async () => { - const f = fixture(); + it("applies narrower broker model grants to preparation and completed generation", async () => { + const f = fixture({ completeStream: true }); f.input.installation.grant.models[0]!.maxOutputTokens = 200; - f.input.installation.grant.models[0]!.providerToolNames = ["web_search"]; + f.input.installation.grant.models[0]!.providerToolNames = ["web_search", "web_fetch"]; f.input.model.grant.models.get(modelId)!.providerTools = [{ type: "provider", name: "web_search", @@ -389,7 +405,14 @@ describe("managed executor broker", () => { try { runtime = await broker.start(f.input); runtime.accept({ kind: "execution" }); - await runtime.agent.stream({ messages: [], abortSignal: new AbortController().signal }); + const stream = await runtime.agent.stream({ + messages: [], + abortSignal: new AbortController().signal, + }); + await Array.fromAsync(stream.toUIMessageStream()); + assertEquals(f.installed!.grant.models[0]!.maxOutputTokens, 100); + assertEquals(f.installed!.grant.models[0]!.providerToolNames, ["web_search"]); + assertEquals(f.input.installation.grant.models[0]!.maxOutputTokens, 200); assertEquals(f.executionAllowed, true); } finally { await runtime?.close(); diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index 1dec1f1501..f501aba8ad 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -143,7 +143,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption ) throw new TypeError("Managed executor installation does not match its session"); const allowedModelIds = new Set(installation.grant.models.map((model) => model.id)); const operationInput = snapshotOperationInput(input); - assertInstalledOperationGrants(operationInput, installation); + constrainInstalledOperationGrants(operationInput, installation); const bindSessionOwnedWork = input.bindSessionOwnedWork; if ( installation.grant.execution.kind === "ephemeral" && @@ -291,7 +291,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption }; } -function assertInstalledOperationGrants( +function constrainInstalledOperationGrants( input: ManagedExecutorOperationInput, installation: ExecutorRuntimeInstall, ): void { @@ -306,6 +306,9 @@ function assertInstalledOperationGrants( if (policy.providerTools.some((tool) => !allowedProviderTools.has(tool.name))) { throw new TypeError("Broker provider tool policy exceeds the installed model grant"); } + // Preparation must produce requests that fit the broker's effective policy. + installed.maxOutputTokens = policy.maxOutputTokens; + installed.providerToolNames = policy.providerTools.map((tool) => tool.name); } const allowedTools = installedToolNames(installation); const allowedSources = new Set([ From de9f7991669f4b34ce57092bcab230e54ca6d769 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:19:46 +0200 Subject: [PATCH 145/194] fix(agent): consolidate private text and replay protections --- .../default-research-artifact-support.ts | 21 ++-- src/agent/artifacts/private-artifact-text.ts | 22 ++-- src/agent/middleware/security/validator.ts | 10 +- src/agent/runtime/chat-stream-handler.ts | 17 +-- src/agent/runtime/index.ts | 1 + .../runtime/provider-replay-emission.test.ts | 55 ++++++++ .../runtime/tool-result-continuation.test.ts | 17 +++ src/agent/runtime/tool-result-continuation.ts | 6 + src/agent/streaming/lifecycle/live-adapter.ts | 47 ++++--- src/agent/streaming/tool-input.ts | 9 +- src/chat/part-field-access.ts | 4 +- src/security/private-regexp.test.ts | 47 +++++++ src/security/private-regexp.ts | 55 ++++++++ src/security/private-text.ts | 10 ++ ...efault-research-private-intrinsics.test.ts | 115 +++++++++++++++++ .../executor-attachment-intrinsics.test.ts | 10 +- .../executor-tool-input-intrinsics.test.ts | 76 ++++++++++++ .../agent/private-output-own-checks.test.ts | 117 ++++++++++++++++++ .../stored-tool-result-intrinsics.test.ts | 111 +++++++++++++++++ 19 files changed, 686 insertions(+), 64 deletions(-) create mode 100644 src/security/private-regexp.test.ts create mode 100644 src/security/private-regexp.ts create mode 100644 tests/integration/agent/default-research-private-intrinsics.test.ts create mode 100644 tests/integration/agent/executor-tool-input-intrinsics.test.ts create mode 100644 tests/integration/agent/private-output-own-checks.test.ts create mode 100644 tests/integration/agent/stored-tool-result-intrinsics.test.ts diff --git a/src/agent/artifacts/default-research-artifact-support.ts b/src/agent/artifacts/default-research-artifact-support.ts index d6b1c3279c..a175bc9497 100644 --- a/src/agent/artifacts/default-research-artifact-support.ts +++ b/src/agent/artifacts/default-research-artifact-support.ts @@ -4,6 +4,7 @@ import { flatMapPrivateArray, joinPrivateArray, mapPrivateArray, + pushPrivateArray, somePrivateArray, } from "#veryfront/security/private-array.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; @@ -33,8 +34,11 @@ export interface DefaultResearchArtifactLogger { debug?: (message: string, metadata?: Record) => void; } +const isArray = Array.isArray; +const hasOwn = Object.hasOwn; + function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } function extractToolResultPath(result: unknown): string | null { @@ -75,6 +79,7 @@ function buildDefaultArtifactsFromResultPath(input: { /** Extract latest user text. */ export function extractLatestUserText(messages: readonly unknown[]): string | null { for (let index = messages.length - 1; index >= 0; index -= 1) { + if (!hasOwn(messages, index)) continue; const message = messages[index]; if (!isRecord(message) || message.role !== "user") { continue; @@ -85,7 +90,7 @@ export function extractLatestUserText(messages: readonly unknown[]): string | nu return content; } - if (!Array.isArray(content)) { + if (!isArray(content)) { continue; } @@ -160,10 +165,10 @@ export async function fetchLatestConversationUserText(input: { const payload = await response.json(); const data = isRecord(payload) ? payload.data : undefined; - const messages = Array.isArray(data) + const messages = isArray(data) ? mapPrivateArray(data, (message) => ({ role: isRecord(message) ? message.role : undefined, - content: isRecord(message) && Array.isArray(message.parts) ? message.parts : [], + content: isRecord(message) && isArray(message.parts) ? message.parts : [], })) : []; @@ -227,12 +232,8 @@ function appendSystemReminder( return instructions; } - const output: ChatSystemMessage[] = []; - for (let index = 0; index < instructions.length; index++) output[index] = instructions[index]!; - output[output.length] = { - role: "system", - content: reminder, - }; + const output = mapPrivateArray(instructions, (message) => message); + pushPrivateArray(output, { role: "system", content: reminder }); return output; } diff --git a/src/agent/artifacts/private-artifact-text.ts b/src/agent/artifacts/private-artifact-text.ts index ee5d55f945..89001d4358 100644 --- a/src/agent/artifacts/private-artifact-text.ts +++ b/src/agent/artifacts/private-artifact-text.ts @@ -2,30 +2,22 @@ import { privateTextEndsWith, privateTextIncludes, privateTextStartsWith, + privateTextToLowerCase, + privateTextTrim, } from "#veryfront/security/private-text.ts"; -import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; - -const apply = Reflect.apply; -const regexpExec = RegExp.prototype.exec; -const regexpReplace = RegExp.prototype[Symbol.replace]; -const trim = String.prototype.trim; -const toLowerCase = String.prototype.toLowerCase; +import { execPrivateRegExp, replacePrivateRegExp } from "#veryfront/security/private-regexp.ts"; /** Captured operations for framework-owned artifact prompts and paths. */ export const privateArtifactText = Object.freeze({ - trim: (value: string): string => apply(trim, value, []), - toLowerCase: (value: string): string => apply(toLowerCase, value, []), + trim: privateTextTrim, + toLowerCase: privateTextToLowerCase, startsWith: privateTextStartsWith, endsWith: privateTextEndsWith, includes: privateTextIncludes, match(value: string, pattern: RegExp): RegExpExecArray | null { - pattern.lastIndex = 0; - return apply(regexpExec, pattern, [value]); + return execPrivateRegExp(pattern, value); }, replace(value: string, pattern: RegExp, replacement: string): string { - // Every caller owns its pattern. Pin exec so the captured replacement - // intrinsic cannot redispatch private input through the mutable prototype. - defineOwnDataProperty(pattern, "exec", regexpExec); - return apply(regexpReplace, pattern, [value, replacement]); + return replacePrivateRegExp(pattern, value, replacement); }, }); diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 836f6a6ea9..c28b4afe1d 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,4 +1,4 @@ -import { privateTextTrimStart } from "#veryfront/security/private-text.ts"; +import { privateTextTrim, privateTextTrimStart } from "#veryfront/security/private-text.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { @@ -9,6 +9,7 @@ import { flatMapPrivateArray, joinPrivateArray, mapPrivateArray, + pushPrivateArray, somePrivateArray, } from "#veryfront/security/private-array.ts"; import { isDeepStrictEqual } from "node:util"; @@ -616,7 +617,10 @@ function extractAdjacentRuns( continue; } if (isEmptyText(message)) continue; - if (dropWhitespaceOnly && joinPrivateArray(messageTextParts(message), "").trim().length === 0) { + if ( + dropWhitespaceOnly && + privateTextTrim(joinPrivateArray(messageTextParts(message), "")).length === 0 + ) { continue; } run.push(message); @@ -631,7 +635,7 @@ function messageTextParts(message: Message, includeAttachments = true): string[] const attachmentContext = includeAttachments && message.role === "user" ? privateTextTrimStart(buildAttachmentContextFromParts(message.parts)) : ""; - if (attachmentContext) texts.push(attachmentContext); + if (attachmentContext) pushPrivateArray(texts, attachmentContext); return texts; } diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 39f917a83d..57a8ced2f4 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -79,6 +79,8 @@ import { import { compareStrings } from "#veryfront/utils/compare.ts"; import { isStatefulTurnCycleError } from "#veryfront/agent/runtime/stateful-turn-lineage.ts"; +const hasOwn = Object.hasOwn; + const logger = serverLogger.component("agent"); const LOCAL_TOOL_COMMIT_GRACE_MS = 250; const LOCAL_TOOL_INPUT_IDLE_MS = 15_000; @@ -622,6 +624,7 @@ function readTraceAttributeString( function finalToolResultIds(state: ChatStreamState): Set { const ids = createPrivateSet(); for (let index = 0; index < state.toolResults.length; index++) { + if (!hasOwn(state.toolResults, index)) continue; const result = state.toolResults[index]!; if (result.preliminary !== true) ids.add(result.toolCallId); } @@ -666,9 +669,9 @@ async function processActiveStream( open: (signal) => source.open(signal).fullStream, options: { availableToolNames: callbacks?.availableToolNames - ? new Set(callbacks.availableToolNames) + ? createPrivateSet(callbacks.availableToolNames) : null, - providerExecutedToolNames: new Set( + providerExecutedToolNames: createPrivateSet( callbacks?.providerExecutedToolNames ?? [], ), }, @@ -841,16 +844,16 @@ export function processStreamInternal( const reasoningParts = createPrivateMap(); let shouldStopForCommittedLocalToolCall = false; let hasActiveLocalToolInput = false; - const providerExecutedToolNames = new Set(callbacks?.providerExecutedToolNames ?? []); + const providerExecutedToolNames = createPrivateSet(callbacks?.providerExecutedToolNames ?? []); const availableToolNames = callbacks?.availableToolNames - ? new Set(callbacks.availableToolNames) + ? createPrivateSet(callbacks.availableToolNames) : null; - const suppressedToolCallIds = new Set(); + const suppressedToolCallIds = createPrivateSet(); // Provider-executed calls whose input completed but whose result has not // arrived yet. While any is outstanding the local-tool commit grace must not // truncate the stream: the provider result can arrive after a separate HTTP // continuation. - const pendingProviderExecutedToolCallIds = new Set(); + const pendingProviderExecutedToolCallIds = createPrivateSet(); const isUnavailableTool = (toolName: string) => availableToolNames !== null && !availableToolNames.has(toolName); @@ -1706,7 +1709,7 @@ export function processStreamInternal( } catch { shadowLifecycleFailed = true; } - const categories = new Set( + const categories = createPrivateSet( observed.categories, ); if (shadowLifecycleFailed) categories.add("shadow_error"); diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 535aefec55..67dc671582 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1190,6 +1190,7 @@ function resolveRuntimeProviderReplayCheckpointEmission( let existingCheckpoint: ProviderReplayCheckpoint | undefined; if (messageId && checkpoints) { for (let index = 0; index < checkpoints.length; index++) { + if (!ObjectHasOwn(checkpoints, index)) continue; const checkpoint = checkpoints[index]; if (checkpoint?.messageId === messageId) { existingCheckpoint = checkpoint; diff --git a/src/agent/runtime/provider-replay-emission.test.ts b/src/agent/runtime/provider-replay-emission.test.ts index fb869ab34a..16446c7a69 100644 --- a/src/agent/runtime/provider-replay-emission.test.ts +++ b/src/agent/runtime/provider-replay-emission.test.ts @@ -39,6 +39,61 @@ function lookupTool(onExecute: () => void = () => {}) { } describe("provider replay checkpoint emission", () => { + it("restores an existing checkpoint without consulting its array find method", async () => { + const prior: ProviderReplayCheckpoint = { + version: 1, + messageId: MESSAGE_ID, + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: "prior-signature" }, + }], + providerBlockPositions: [0], + providerMessageBlockCounts: [1], + totalPartCount: 1, + }; + const checkpoints = [prior]; + let observations = 0; + Object.defineProperty(checkpoints, "find", { + get() { + observations++; + return Array.prototype.find; + }, + }); + const persisted: ProviderReplayCheckpoint[] = []; + const model = scriptedModel([{ + text: "continued", + providerMetadata: metadata([{ type: "text", text: "continued" }]), + }], { + modelId: "anthropic/private-checkpoint-selection", + provider: "anthropic", + only: "generate", + }); + const config = { + id: "private-checkpoint-selection", + model: "anthropic/private-checkpoint-selection", + system: "Continue.", + skills: false, + maxSteps: 1, + resolveModelTransport: () => ({ model }), + __vfProviderReplayCheckpoints: checkpoints, + __vfProviderReplayCheckpointMessageId: MESSAGE_ID, + __vfPersistProviderReplayCheckpoint: (checkpoint: ProviderReplayCheckpoint) => { + persisted.push(checkpoint); + }, + } as AgentConfig & RuntimeToolFilterConfig; + await agent(config).generate({ + input: [ + { id: MESSAGE_ID, role: "assistant", parts: [] }, + { id: "current-user", role: "user", parts: [{ type: "text", text: "Continue" }] }, + ], + }); + assertEquals(persisted[0]?.providerBlocks[0]?.block, prior.providerBlocks[0]?.block); + assertEquals(persisted[0]?.providerMessageBlockCounts, [1, 1]); + assertEquals(observations, 0); + }); + it("retains pre-signature groups and appends to the delivered run checkpoint", () => { const prior: ProviderReplayCheckpoint = { version: 1, diff --git a/src/agent/runtime/tool-result-continuation.test.ts b/src/agent/runtime/tool-result-continuation.test.ts index 1b2386e7a6..9f694ef1b3 100644 --- a/src/agent/runtime/tool-result-continuation.test.ts +++ b/src/agent/runtime/tool-result-continuation.test.ts @@ -22,6 +22,23 @@ function createState( } describe("agent runtime streamed tool result collection", () => { + it("keeps the last final result without consulting the stored-result iterator", () => { + const first = { toolCallId: "call", toolName: "inspect", output: "first" }; + const final = { toolCallId: "call", toolName: "inspect", output: "synthetic private output" }; + const results = [first, { ...final, preliminary: true }, final]; + let observations = 0; + Object.defineProperty(results, Symbol.iterator, { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }); + const collected = collectFinalStreamToolResults({ toolResults: results }); + assertEquals(collected.size, 1); + assertEquals(collected.get("call"), final); + assertEquals(observations, 0); + }); + it("continues after suppressing unavailable streamed tool calls", () => { const shouldContinue = shouldContinueAfterStreamStep({ accumulatedText: "", diff --git a/src/agent/runtime/tool-result-continuation.ts b/src/agent/runtime/tool-result-continuation.ts index 54113e4d66..17f3e3a311 100644 --- a/src/agent/runtime/tool-result-continuation.ts +++ b/src/agent/runtime/tool-result-continuation.ts @@ -10,6 +10,8 @@ import type { import { parseToolArgs } from "./tool-helpers.ts"; import type { RuntimeGenerateToolResult, RuntimeToolSet } from "./runtime-tool-types.ts"; +const hasOwn = Object.hasOwn; + export { getToolResultError } from "#veryfront/tool/result.ts"; export function createToolResultMessage( @@ -79,6 +81,7 @@ export function collectFinalStreamToolResults( const finalToolResults = createPrivateMap(); for (let index = 0; index < state.toolResults.length; index++) { + if (!hasOwn(state.toolResults, index)) continue; const toolResult = state.toolResults[index]!; if (toolResult.preliminary === true) { continue; @@ -96,12 +99,14 @@ export function collectPersistedToolResults( const persistedToolResults = createPrivateMap(); for (let index = 0; index < messages.length; index++) { + if (!hasOwn(messages, index)) continue; const message = messages[index]!; if (message.role !== "tool") { continue; } for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!hasOwn(message.parts, partIndex)) continue; const part = message.parts[partIndex]!; if (!isToolResultPart(part)) { continue; @@ -120,6 +125,7 @@ export function collectGeneratedToolResults( const generatedToolResults = createPrivateMap(); for (let index = 0; index < (toolResults?.length ?? 0); index++) { + if (!hasOwn(toolResults!, index)) continue; const toolResult = toolResults![index]!; generatedToolResults.set(toolResult.toolCallId, toolResult); } diff --git a/src/agent/streaming/lifecycle/live-adapter.ts b/src/agent/streaming/lifecycle/live-adapter.ts index 1aa64ded44..48362684e7 100644 --- a/src/agent/streaming/lifecycle/live-adapter.ts +++ b/src/agent/streaming/lifecycle/live-adapter.ts @@ -1,3 +1,5 @@ +import { filterPrivateArray, mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import type { ChatStreamState, StreamingToolResult, @@ -223,25 +225,29 @@ export function applyLifecycleSnapshotToChatStreamState( snapshot: Readonly, ): void { state.accumulatedText = snapshot.accumulatedText; - state.reasoningParts = snapshot.reasoning.map((part) => ({ ...part })); + state.reasoningParts = mapPrivateArray( + snapshot.reasoning, + (part) => ({ __proto__: null, ...part }), + ); state.finishReason = snapshot.finishReason; state.providerMetadata = snapshot.providerMetadata; - state.toolCalls = new Map( - snapshot.tools.filter(isAvailableTool).map((tool) => [ - tool.id, - { - id: tool.id, - name: tool.name, - arguments: tool.inputText, - inputDeltas: [...tool.inputDeltas], - inputAnnounced: isInputAvailable(tool), - inputAvailable: isInputAvailable(tool), - ...(tool.providerExecuted !== undefined ? { providerExecuted: tool.providerExecuted } : {}), - ...(tool.dynamic !== undefined ? { dynamic: tool.dynamic } : {}), - }, - ]), - ); - state.toolResults = snapshot.tools.filter(isProviderToolTerminal).map( + state.toolCalls = createPrivateMap(); + const availableTools = filterPrivateArray(snapshot.tools, isAvailableTool); + for (let index = 0; index < availableTools.length; index++) { + const tool = availableTools[index]!; + state.toolCalls.set(tool.id, { + id: tool.id, + name: tool.name, + arguments: tool.inputText, + inputDeltas: mapPrivateArray(tool.inputDeltas, (delta) => delta), + inputAnnounced: isInputAvailable(tool), + inputAvailable: isInputAvailable(tool), + ...(tool.providerExecuted !== undefined ? { providerExecuted: tool.providerExecuted } : {}), + ...(tool.dynamic !== undefined ? { dynamic: tool.dynamic } : {}), + }); + } + state.toolResults = mapPrivateArray( + filterPrivateArray(snapshot.tools, isProviderToolTerminal), (tool): StreamingToolResult => ({ toolCallId: tool.id, toolName: tool.name, @@ -252,8 +258,9 @@ export function applyLifecycleSnapshotToChatStreamState( ...(tool.preliminary !== undefined ? { preliminary: tool.preliminary } : {}), }), ); - state.suppressedToolCalls = snapshot.tools - .filter((tool) => tool.rejectionReason === "unavailable") - .map((tool) => ({ id: tool.id, name: tool.name })); + state.suppressedToolCalls = mapPrivateArray( + filterPrivateArray(snapshot.tools, (tool) => tool.rejectionReason === "unavailable"), + (tool) => ({ id: tool.id, name: tool.name }), + ); state.usage = toLegacyRuntimeUsage(snapshot.usage); } diff --git a/src/agent/streaming/tool-input.ts b/src/agent/streaming/tool-input.ts index ef9a86be7a..4a8fb31049 100644 --- a/src/agent/streaming/tool-input.ts +++ b/src/agent/streaming/tool-input.ts @@ -2,19 +2,18 @@ import { privateTextEndsWith as endsWith, privateTextSlice as slice, privateTextStartsWith as startsWith, + privateTextTrim as trim, privateTextTrimStart as trimStart, } from "#veryfront/security/private-text.ts"; import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; const logger = serverLogger.component("agent-tool-input"); -const apply = Reflect.apply; -const stringTrim = String.prototype.trim; +const isArray = Array.isArray; const min = Math.min; -const trim = (value: string): string => apply(stringTrim, value, []); function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } /** Normalize provider tool input by removing transient empty-object prefixes. */ @@ -157,7 +156,7 @@ export function parseToolInputObject(input: unknown): Record { return parsed; } logger.warn("Tool input decoded to a non-record value; using empty object", { - parsedType: Array.isArray(parsed) ? "array" : typeof parsed, + parsedType: isArray(parsed) ? "array" : typeof parsed, inputLength: input.length, }); } catch (error) { diff --git a/src/chat/part-field-access.ts b/src/chat/part-field-access.ts index 7b505ab029..974cb56495 100644 --- a/src/chat/part-field-access.ts +++ b/src/chat/part-field-access.ts @@ -28,9 +28,7 @@ export function getStringField(value: unknown, field: string, fallback: string): /** Return a string field when present, else undefined. */ export function getOptionalStringField(value: unknown, key: string): string | undefined { - if (!isRecord(value)) return undefined; - - const field = value[key]; + const field = isRecord(value) ? value[key] : undefined; return typeof field === "string" ? field : undefined; } diff --git a/src/security/private-regexp.test.ts b/src/security/private-regexp.test.ts new file mode 100644 index 0000000000..9bdba2a6ba --- /dev/null +++ b/src/security/private-regexp.test.ts @@ -0,0 +1,47 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { execPrivateRegExp, replacePrivateRegExp, testPrivateRegExp } from "./private-regexp.ts"; + +describe("private regex operations", () => { + it("matches global and frozen sticky patterns without changing caller state", () => { + const global = /private/g; + assertEquals(testPrivateRegExp(global, "private text"), true); + assertEquals(testPrivateRegExp(global, "private text"), true); + assertEquals(global.lastIndex, 0); + const sticky = /private/y; + sticky.lastIndex = 7; + Object.freeze(sticky); + assertEquals(execPrivateRegExp(sticky, "prefix private")?.index, 7); + assertEquals(testPrivateRegExp(sticky, "prefix private"), true); + assertEquals(sticky.lastIndex, 7); + }); + + it("retains captures and replacement substitutions without caller matcher overrides", () => { + const pattern = /(private)-(text)/g; + let observations = 0; + Object.defineProperty(pattern, "exec", { + value() { + observations++; + return null; + }, + }); + assertEquals( + replacePrivateRegExp(pattern, "private-text private-text", "$2:$1"), + "text:private text:private", + ); + assertEquals(pattern.lastIndex, 0); + assertEquals(execPrivateRegExp(/(private)-(text)/, "private-text")?.[1], "private"); + assertEquals(testPrivateRegExp(/private/, "private-text"), true); + assertEquals(testPrivateRegExp(/absent/, "private-text"), false); + assertEquals(observations, 0); + }); + + it("preserves sticky positions and frozen input patterns", () => { + const pattern = /private/y; + pattern.lastIndex = 7; + Object.freeze(pattern); + assertEquals(replacePrivateRegExp(pattern, "prefix private", "kept"), "prefix kept"); + assertEquals(pattern.lastIndex, 7); + assertEquals(replacePrivateRegExp(/PRIVATE/gi, "private Private", "kept"), "kept kept"); + }); +}); diff --git a/src/security/private-regexp.ts b/src/security/private-regexp.ts new file mode 100644 index 0000000000..5194cf50a1 --- /dev/null +++ b/src/security/private-regexp.ts @@ -0,0 +1,55 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const NativeRegExp = RegExp; +const apply = Reflect.apply; +const exec = RegExp.prototype.exec; +const replace = RegExp.prototype[Symbol.replace]; +const source = Object.getOwnPropertyDescriptor(RegExp.prototype, "source")!.get!; +const flagNames = [ + ["hasIndices", "d"], + ["global", "g"], + ["ignoreCase", "i"], + ["multiline", "m"], + ["dotAll", "s"], + ["unicode", "u"], + ["unicodeSets", "v"], + ["sticky", "y"], +] as const; +const flagGetters = flagNames.map(([name, flag]) => ({ + name, + flag, + get: Object.getOwnPropertyDescriptor(RegExp.prototype, name)?.get, +})); + +/** Match private text without dispatching through a mutable matcher method. */ +export function execPrivateRegExp(pattern: RegExp, text: string): RegExpExecArray | null { + return apply(exec, copyPrivateMatcher(pattern), [text]) as RegExpExecArray | null; +} + +/** Test private text with the captured native matcher. */ +export function testPrivateRegExp(pattern: RegExp, text: string): boolean { + return execPrivateRegExp(pattern, text) !== null; +} + +function copyPrivateMatcher(pattern: RegExp): RegExp { + let flags = ""; + const enabled: boolean[] = []; + for (let index = 0; index < flagGetters.length; index++) { + const entry = flagGetters[index]!; + const active = entry.get !== undefined && apply(entry.get, pattern, []); + defineOwnDataProperty(enabled, index, active as boolean); + if (active) flags += entry.flag; + } + const matcher = new NativeRegExp(apply(source, pattern, []), flags); + matcher.lastIndex = pattern.lastIndex; + defineOwnDataProperty(matcher, "exec", exec); + for (let index = 0; index < flagGetters.length; index++) { + defineOwnDataProperty(matcher, flagGetters[index]!.name, enabled[index]); + } + return matcher; +} + +/** Replace text using a private matcher without mutating caller-owned regex state. */ +export function replacePrivateRegExp(pattern: RegExp, text: string, replacement: string): string { + return apply(replace, copyPrivateMatcher(pattern), [text, replacement]) as string; +} diff --git a/src/security/private-text.ts b/src/security/private-text.ts index 2d10a7d3cb..372f826507 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -4,6 +4,16 @@ const slice = String.prototype.slice; const includes = String.prototype.includes; const endsWith = String.prototype.endsWith; const trimStart = String.prototype.trimStart; +const trim = String.prototype.trim; +const toLowerCase = String.prototype.toLowerCase; + +export function privateTextToLowerCase(value: string): string { + return apply(toLowerCase, value, []) as string; +} + +export function privateTextTrim(value: string): string { + return apply(trim, value, []) as string; +} export function privateTextEndsWith(value: string, search: string): boolean { return apply(endsWith, value, [search]) as boolean; diff --git a/tests/integration/agent/default-research-private-intrinsics.test.ts b/tests/integration/agent/default-research-private-intrinsics.test.ts new file mode 100644 index 0000000000..9292bd6863 --- /dev/null +++ b/tests/integration/agent/default-research-private-intrinsics.test.ts @@ -0,0 +1,115 @@ +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + applyDefaultResearchArtifactPath, + type DefaultResearchArtifactContext, + shouldRetryCreateResearchArtifactAsUpdate, + updateDefaultResearchArtifacts, +} from "#veryfront/agent/artifacts/default-research-artifact-support.ts"; + +for (const hooks of [false, true]) { + describe(`private research normalization ${hooks ? "hooks" : "baseline"}`, () => { + it("keeps prompt and model paths private while preserving research routing", () => { + const marker = "synthetic-private-research"; + const prompt = + `/research Research ${marker} and save findings to the project.`; + const context: DefaultResearchArtifactContext = { parentRunId: "run-one" }; + const test = RegExp.prototype.test; + const exec = RegExp.prototype.exec; + const replaceRegExp = RegExp.prototype[Symbol.replace]; + const replace = String.prototype.replace; + const trim = String.prototype.trim; + const lower = String.prototype.toLowerCase; + const startsWith = String.prototype.startsWith; + const endsWith = String.prototype.endsWith; + const match = String.prototype.match; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + const observe = (value: unknown) => { + if (typeof value === "string" && apply(includes, value, [marker])) observations++; + }; + let system: ReturnType = ""; + let normalized: Record = {}; + let retry = false; + try { + if (hooks) { + RegExp.prototype.test = function (input) { + observe(input); + return apply(test, this, [input]); + }; + RegExp.prototype.exec = function (input) { + observe(input); + return apply(exec, this, [input]); + }; + RegExp.prototype[Symbol.replace] = function ( + input: string, + replacement: string | ((substring: string, ...args: unknown[]) => string), + ) { + observe(input); + return apply(replaceRegExp, this, [input, replacement]); + }; + String.prototype.replace = function (...args) { + observe(this); + return apply(replace, this, args); + }; + String.prototype.trim = function () { + observe(this); + return apply(trim, this, []); + }; + String.prototype.toLowerCase = function () { + observe(this); + return apply(lower, this, []); + }; + String.prototype.startsWith = function (...args) { + observe(this); + return apply(startsWith, this, args); + }; + String.prototype.endsWith = function (...args) { + observe(this); + return apply(endsWith, this, args); + }; + String.prototype.match = function (...args) { + observe(this); + return apply(match, this, args); + }; + } + system = updateDefaultResearchArtifacts({ + taskContext: context, + latestUserText: prompt, + system: "Base instructions", + }); + normalized = applyDefaultResearchArtifactPath("create_file", { + path: `/research/${marker}.md`, + content: "Synthetic report", + }, context); + retry = shouldRetryCreateResearchArtifactAsUpdate({ + toolName: "create_file", + toolInput: normalized, + taskContext: context, + error: { code: "ALREADY_EXISTS", message: "File already exists" }, + }); + } finally { + if (hooks) { + RegExp.prototype.test = test; + RegExp.prototype.exec = exec; + RegExp.prototype[Symbol.replace] = replaceRegExp; + String.prototype.replace = replace; + String.prototype.trim = trim; + String.prototype.toLowerCase = lower; + String.prototype.startsWith = startsWith; + String.prototype.endsWith = endsWith; + String.prototype.match = match; + } + } + assertEquals(context.defaultResearchArtifacts?.topicSlug, marker); + assertEquals(normalized.path, `research/${marker}/report.md`); + assertStringIncludes( + typeof system === "string" ? system : "", + `/research/${marker}/runs/run-one.report.md`, + ); + assertEquals(retry, true); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/executor-attachment-intrinsics.test.ts b/tests/integration/agent/executor-attachment-intrinsics.test.ts index 8973eebed3..c16f2cceb4 100644 --- a/tests/integration/agent/executor-attachment-intrinsics.test.ts +++ b/tests/integration/agent/executor-attachment-intrinsics.test.ts @@ -1,3 +1,4 @@ +import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; @@ -6,7 +7,7 @@ import { convertToTextGenerationRuntimeRequestMessages } from "#veryfront/agent/ for (const replaceMethods of [false, true]) { describe(`private attachment conversion ${replaceMethods ? "hooks" : "baseline"}`, () => { - it("escapes annotations and trims assistant tails without exposing private contents", () => { + it("escapes annotations and trims assistant tails without exposing private contents", async () => { const marker = "synthetic-private-attachment"; const messages: Message[] = [ { @@ -69,6 +70,13 @@ for (const replaceMethods of [false, true]) { }; } converted = convertToTextGenerationRuntimeRequestMessages(messages); + await securityMiddleware({ input: { maxLength: 8192 } })({ + agentId: "synthetic", + model: "veryfront-cloud/openai/gpt-5.4", + input: messages, + data: {}, + platform: {}, + }, () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" })); } finally { if (replaceMethods) { Array.prototype.map = map; diff --git a/tests/integration/agent/executor-tool-input-intrinsics.test.ts b/tests/integration/agent/executor-tool-input-intrinsics.test.ts new file mode 100644 index 0000000000..99dce0cfad --- /dev/null +++ b/tests/integration/agent/executor-tool-input-intrinsics.test.ts @@ -0,0 +1,76 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + mergeToolCallInput, + mergeToolInputDelta, + parseToolInputObject, + stripLeadingEmptyObjectPlaceholder, +} from "#veryfront/agent/streaming/tool-input.ts"; + +for (const hooks of [false, true]) { + describe(`private tool input ${hooks ? "hooks" : "baseline"}`, () => { + it("normalizes complete and fragmented arguments without mutable string calls", () => { + const marker = "synthetic-private-tool-input"; + const raw = JSON.stringify({ query: marker }); + const split = raw.indexOf(marker) + 8; + const first = raw.slice(0, split); + const second = raw.slice(split); + const trim = String.prototype.trim; + const trimStart = String.prototype.trimStart; + const startsWith = String.prototype.startsWith; + const endsWith = String.prototype.endsWith; + const slice = String.prototype.slice; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + const observe = (value: unknown) => { + if (apply(includes, value, [marker])) observations++; + }; + let normalized = ""; + let merged = ""; + let completed = ""; + let parsed: Record = {}; + try { + if (hooks) { + String.prototype.trim = function () { + observe(this); + return apply(trim, this, []); + }; + String.prototype.trimStart = function () { + observe(this); + return apply(trimStart, this, []); + }; + String.prototype.startsWith = function (...args) { + observe(this); + return apply(startsWith, this, args); + }; + String.prototype.endsWith = function (...args) { + observe(this); + return apply(endsWith, this, args); + }; + String.prototype.slice = function (...args) { + observe(this); + return apply(slice, this, args); + }; + } + normalized = stripLeadingEmptyObjectPlaceholder(` {} {} ${raw} `); + merged = mergeToolInputDelta(first, second); + completed = mergeToolCallInput(raw, " {} "); + parsed = parseToolInputObject(`{}${raw}`); + } finally { + if (hooks) { + String.prototype.trim = trim; + String.prototype.trimStart = trimStart; + String.prototype.startsWith = startsWith; + String.prototype.endsWith = endsWith; + String.prototype.slice = slice; + } + } + assertEquals(normalized, raw); + assertEquals(merged, raw); + assertEquals(completed, raw); + assertEquals(parsed, { query: marker }); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/private-output-own-checks.test.ts b/tests/integration/agent/private-output-own-checks.test.ts new file mode 100644 index 0000000000..a4defdc572 --- /dev/null +++ b/tests/integration/agent/private-output-own-checks.test.ts @@ -0,0 +1,117 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorModelBroker, + createExecutorModelRuntimeResolver, +} from "#veryfront/agent/hosted/executor-model-bridge.ts"; +import { + createToolExecutionDataEventBridgeStream, + type ToolExecutionDataEventPublisher, +} from "#veryfront/agent/streaming/tool-execution-data-event-bridge.ts"; + +for (const hooks of [false, true]) { + describe(`private output ownership ${hooks ? "hooks" : "baseline"}`, () => { + it("checks managed model chunks without a mutable Object.hasOwn call", async () => { + const marker = "synthetic-private-owned-model-output"; + const modelId = "veryfront-cloud/openai/synthetic-model"; + const source = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: marker }); + controller.close(); + }, + }); + const binding = { allocationId: "own-model", invocationId: "own-model", generation: 1 }; + const forward = new TransformStream(); + const backward = new TransformStream(); + const allowedModelIds = new Set([modelId]); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createExecutorModelBroker({ + allowedModelIds, + resolveModelRuntime: () => ({ + modelId: "synthetic-model", + provider: "openai", + doGenerate: () => Promise.reject(new Error("Unexpected generation")), + doStream: () => Promise.resolve({ stream: source }), + }), + }), + }); + const executor = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const hasOwn = Object.hasOwn; + const descriptor = Object.getOwnPropertyDescriptor; + let observations = 0; + let chunks: unknown[] = []; + try { + const resolve = await createExecutorModelRuntimeResolver({ + channel: executor, + allowedModelIds, + }); + if (hooks) { + Object.hasOwn = function (value, key) { + if ( + value !== null && typeof value === "object" && + descriptor(value, "delta")?.value === marker + ) observations++; + return hasOwn(value, key); + }; + } + const result = await resolve(modelId)!.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Synthetic input" }] }], + }); + chunks = await Array.fromAsync(result.stream); + } finally { + if (hooks) Object.hasOwn = hasOwn; + executor.close(); + broker.close(); + await Promise.all([executor.settled, broker.settled]); + } + assertEquals(chunks, [{ type: "text-delta", delta: marker }]); + assertEquals(observations, 0); + }); + + it("publishes named tool values without exposing the event to an own-property hook", async () => { + const event = { + type: "data", + name: "synthetic", + value: { text: "synthetic-private-owned-event" }, + }; + let baseController: ReadableStreamDefaultController | undefined; + const baseStream = new ReadableStream({ + start(controller) { + baseController = controller; + }, + }); + let publish: ToolExecutionDataEventPublisher | undefined; + const stream = createToolExecutionDataEventBridgeStream({ + baseStream, + installPublisher: (next) => { + publish = next; + }, + }); + const hasOwn = Object.hasOwn; + let observations = 0; + try { + if (hooks) { + Object.hasOwn = function (value, key) { + if (value === event) observations++; + return hasOwn(value, key); + }; + } + publish?.(event); + } finally { + if (hooks) Object.hasOwn = hasOwn; + baseController?.close(); + } + const body = await new Response(stream).text(); + assertStringIncludes(body, '"type":"data-synthetic"'); + assertStringIncludes(body, '"text":"synthetic-private-owned-event"'); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/stored-tool-result-intrinsics.test.ts b/tests/integration/agent/stored-tool-result-intrinsics.test.ts new file mode 100644 index 0000000000..c614f4d91b --- /dev/null +++ b/tests/integration/agent/stored-tool-result-intrinsics.test.ts @@ -0,0 +1,111 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createMockResult, + createSSECollector, +} from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; +import { createStreamState, processStream } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { collectFinalStreamToolResults } from "#veryfront/agent/runtime/tool-result-continuation.ts"; + +for (const probe of ["baseline", "iterator", "filter", "map", "some"]) { + describe(`private stored tool results ${probe}`, () => { + it("retains final provider output without mutable collection callbacks", async () => { + const marker = "synthetic-private-stored-output"; + const output = { text: marker }; + const state = createStreamState(); + const { controller, encoder } = createSSECollector(); + const result = createMockResult([ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "web_fetch", + input: {}, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "web_fetch", + output, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "web_fetch", + output, + providerExecuted: true, + }, + { + type: "tool-call", + toolCallId: "call-2", + toolName: "web_fetch", + input: {}, + providerExecuted: true, + }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]); + const iterator = Array.prototype[Symbol.iterator]; + const filter = Array.prototype.filter; + const map = Array.prototype.map; + const some = Array.prototype.some; + const hasOwn = Object.hasOwn; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + const observe = (values: unknown[]) => { + for (let index = 0; index < values.length; index++) { + const value = values[index]; + if ( + value && typeof value === "object" && !hasOwn(value, "type") && + hasOwn(value, "output") && + apply(includes, stringify(value) ?? "", [marker]) + ) observations++; + } + }; + let collected: ReturnType | undefined; + try { + if (probe === "iterator") { + Array.prototype[Symbol.iterator] = function () { + observe(this); + return apply(iterator, this, []); + }; + } + if (probe === "filter") { + Array.prototype.filter = (function (this: unknown[], ...args: unknown[]) { + observe(this); + return apply(filter, this, args); + }) as typeof filter; + } + if (probe === "map") { + Array.prototype.map = (function (this: unknown[], ...args: unknown[]) { + observe(this); + return apply(map, this, args); + }) as typeof map; + } + if (probe === "some") { + Array.prototype.some = function (...args) { + observe(this); + return apply(some, this, args); + }; + } + await processStream(result, state, controller, encoder, "text", { + providerExecutedToolNames: ["web_fetch"], + availableToolNames: ["web_fetch"], + }); + collected = collectFinalStreamToolResults(state); + } finally { + if (probe !== "baseline") { + Array.prototype[Symbol.iterator] = iterator; + Array.prototype.filter = filter; + Array.prototype.map = map; + Array.prototype.some = some; + } + } + assertEquals(collected?.get("call-1")?.output, output); + assertEquals(observations, 0); + }); + }); +} From 00e74c17d8c5fa46e7348b2bc9ab55580300783c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:23:26 +0200 Subject: [PATCH 146/194] fix(agent): inspect accumulated text with captured trim --- src/agent/runtime/tool-result-continuation.ts | 3 ++- .../agent/executor-tool-input-intrinsics.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/agent/runtime/tool-result-continuation.ts b/src/agent/runtime/tool-result-continuation.ts index 17f3e3a311..3a7d0e8f01 100644 --- a/src/agent/runtime/tool-result-continuation.ts +++ b/src/agent/runtime/tool-result-continuation.ts @@ -1,3 +1,4 @@ +import { privateTextTrim } from "#veryfront/security/private-text.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { pushPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; import { type Message, type MessagePart, type ToolResultPart } from "../types.ts"; @@ -134,7 +135,7 @@ export function collectGeneratedToolResults( } export function hasSubstantiveAssistantText(text: string | undefined): boolean { - return typeof text === "string" && text.trim().length > 0; + return typeof text === "string" && privateTextTrim(text).length > 0; } export function isClientRecoverablePlaceholderToolCall( diff --git a/tests/integration/agent/executor-tool-input-intrinsics.test.ts b/tests/integration/agent/executor-tool-input-intrinsics.test.ts index 99dce0cfad..9552ee8de8 100644 --- a/tests/integration/agent/executor-tool-input-intrinsics.test.ts +++ b/tests/integration/agent/executor-tool-input-intrinsics.test.ts @@ -1,3 +1,4 @@ +import { hasSubstantiveAssistantText } from "#veryfront/agent/runtime/tool-result-continuation.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { @@ -29,6 +30,8 @@ for (const hooks of [false, true]) { let normalized = ""; let merged = ""; let completed = ""; + let substantive = false; + let whitespaceOnly = true; let parsed: Record = {}; try { if (hooks) { @@ -57,6 +60,8 @@ for (const hooks of [false, true]) { merged = mergeToolInputDelta(first, second); completed = mergeToolCallInput(raw, " {} "); parsed = parseToolInputObject(`{}${raw}`); + substantive = hasSubstantiveAssistantText(marker); + whitespaceOnly = hasSubstantiveAssistantText(" \n\t "); } finally { if (hooks) { String.prototype.trim = trim; @@ -70,6 +75,8 @@ for (const hooks of [false, true]) { assertEquals(merged, raw); assertEquals(completed, raw); assertEquals(parsed, { query: marker }); + assertEquals(substantive, true); + assertEquals(whitespaceOnly, false); assertEquals(observations, 0); }); }); From a7e916c6dbbc1e441a620d4fde88950f94f89b79 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:21:18 +0200 Subject: [PATCH 147/194] fix(agent): protect incomplete tool argument previews --- src/agent/runtime/tool-result-continuation.ts | 4 +- .../continuation-text-intrinsics.test.ts | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/agent/continuation-text-intrinsics.test.ts diff --git a/src/agent/runtime/tool-result-continuation.ts b/src/agent/runtime/tool-result-continuation.ts index 3a7d0e8f01..aefdcd4a23 100644 --- a/src/agent/runtime/tool-result-continuation.ts +++ b/src/agent/runtime/tool-result-continuation.ts @@ -1,4 +1,4 @@ -import { privateTextTrim } from "#veryfront/security/private-text.ts"; +import { privateTextSlice, privateTextTrim } from "#veryfront/security/private-text.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { pushPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; import { type Message, type MessagePart, type ToolResultPart } from "../types.ts"; @@ -312,7 +312,7 @@ export function materializeStreamedToolCall( kind: "incomplete", part: basePart, partialArgumentsLength: tc.arguments.length, - partialArgumentsPreview: tc.arguments.slice(0, 200), + partialArgumentsPreview: privateTextSlice(tc.arguments, 0, 200), }; } diff --git a/tests/integration/agent/continuation-text-intrinsics.test.ts b/tests/integration/agent/continuation-text-intrinsics.test.ts new file mode 100644 index 0000000000..906b9c9918 --- /dev/null +++ b/tests/integration/agent/continuation-text-intrinsics.test.ts @@ -0,0 +1,55 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + materializeStreamedToolCall, + shouldContinueAfterStreamStep, +} from "#veryfront/agent/runtime/tool-result-continuation.ts"; + +describe("private continuation text", () => { + for (const method of ["trim", "slice"] as const) { + it(`keeps accumulated output and incomplete arguments out of replaced ${method}`, () => { + const marker = "synthetic-private-continuation"; + const original = String.prototype[method]; + const apply = Reflect.apply; + const includes = String.prototype.includes; + let observations = 0; + let continues; + let materialized; + try { + Object.defineProperty(String.prototype, method, { + configurable: true, + writable: true, + value: function (this: string, ...args: unknown[]) { + if (apply(includes, this, [marker])) observations++; + return apply(original, this, args); + }, + }); + continues = shouldContinueAfterStreamStep({ + accumulatedText: ` ${marker} `, + finishReason: "stop", + toolCalls: new Map(), + toolResults: [], + }); + materialized = materializeStreamedToolCall({ + id: "synthetic-call", + name: "inspect", + arguments: marker, + inputAvailable: false, + }); + } finally { + Object.defineProperty(String.prototype, method, { + configurable: true, + writable: true, + value: original, + }); + } + assertEquals(continues, false); + assertEquals(materialized?.kind, "incomplete"); + if (materialized?.kind === "incomplete") { + assertEquals(materialized.partialArgumentsPreview, marker); + } + assertEquals(observations, 0); + }); + } +}); From 367c80f522f7be1ddf31039450e0a9200c560e02 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:42:38 +0200 Subject: [PATCH 148/194] fix(agent): protect private map entry destructuring --- src/security/private-map.test.ts | 41 ++++++++++++++ src/security/private-map.ts | 39 ++++++++++++-- .../private-map-entry-intrinsics.test.ts | 53 +++++++++++++++++++ 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 tests/integration/agent/private-map-entry-intrinsics.test.ts diff --git a/src/security/private-map.test.ts b/src/security/private-map.test.ts index 2798798cbe..23067d3051 100644 --- a/src/security/private-map.test.ts +++ b/src/security/private-map.test.ts @@ -3,6 +3,47 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { createPrivateMap } from "./private-map.ts"; describe("private maps", () => { + it("destructures entry tuples without consulting an inherited array iterator", () => { + const map = createPrivateMap(); + const value = { text: "Synthetic private map value" }; + map.set("key", value); + let observations = 0; + for (const entries of [map.entries(), map[Symbol.iterator]()]) { + const entry = entries.next().value!; + Object.setPrototypeOf( + entry, + Object.create(Array.prototype, { + [Symbol.iterator]: { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }, + }), + ); + const [key, selected] = entry; + assertEquals(key, "key"); + assertStrictEquals(selected, value); + } + assertEquals(observations, 0); + }); + + it("keeps entry values mutable and entry iterators exhausted after completion", () => { + const map = createPrivateMap(); + map.set("key", 1); + const entry = map.entries().next().value!; + const iterator = entry[Symbol.iterator](); + assertEquals(iterator.next(), { done: false, value: "key" }); + entry[1] = 2; + assertEquals(iterator.next(), { done: false, value: 2 }); + assertEquals(iterator.next(), { done: true, value: undefined }); + entry.push(3); + assertEquals(iterator.next(), { done: true, value: undefined }); + assertEquals(map.get("key"), 1); + assertEquals(Object.getPrototypeOf(iterator), null); + assertEquals(Object.isFrozen(iterator), true); + }); + it("preserves identity, insertion order, updates, and deletion through bound operations", () => { const map = createPrivateMap(); const first = { text: "first" }; diff --git a/src/security/private-map.ts b/src/security/private-map.ts index 3c7f138d66..a5b1400b79 100644 --- a/src/security/private-map.ts +++ b/src/security/private-map.ts @@ -4,6 +4,7 @@ const MapConstructor = Map; const apply = Reflect.apply; const defineProperty = Object.defineProperty; const freeze = Object.freeze; +const hasOwn = Object.hasOwn; const mapGet = Map.prototype.get; const mapSet = Map.prototype.set; const mapHas = Map.prototype.has; @@ -17,14 +18,44 @@ const iteratorSymbol: typeof Symbol.iterator = Symbol.iterator; const iteratorNext = Object.getPrototypeOf(new MapConstructor().values()).next; const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!; +function protectEntry(entry: [K, V]): [K, V] { + defineOwnDataProperty(entry, iteratorSymbol, () => { + let index = 0; + let done = false; + return freeze({ + __proto__: null, + next: () => { + if (done || index >= entry.length) { + done = true; + return { __proto__: null, done: true, value: undefined }; + } + const value = hasOwn(entry, index) ? entry[index] : undefined; + index++; + return { __proto__: null, done: false, value }; + }, + [iteratorSymbol]() { + return this; + }, + }); + }); + return entry; +} + /** A private map with captured construction, operations, and iterator advancement. */ export function createPrivateMap(): Map { const map = new MapConstructor(); - const iterate = (method: (this: Map) => IterableIterator) => { + const iterate = ( + method: (this: Map) => IterableIterator, + protect?: (value: T) => T, + ) => { const iterator = apply(method, map, []); return freeze({ __proto__: null, - next: () => apply(iteratorNext, iterator, []) as IteratorResult, + next: () => { + const result = apply(iteratorNext, iterator, []) as IteratorResult; + if (!result.done && protect) result.value = protect(result.value); + return result; + }, [iteratorSymbol]() { return this; }, @@ -42,8 +73,8 @@ export function createPrivateMap(): Map { }); defineOwnDataProperty(map, "values", () => iterate(mapValues)); defineOwnDataProperty(map, "keys", () => iterate(mapKeys)); - defineOwnDataProperty(map, "entries", () => iterate(mapEntries)); - defineOwnDataProperty(map, iteratorSymbol, () => iterate(mapEntries)); + defineOwnDataProperty(map, "entries", () => iterate(mapEntries, protectEntry)); + defineOwnDataProperty(map, iteratorSymbol, () => iterate(mapEntries, protectEntry)); defineOwnDataProperty( map, "forEach", diff --git a/tests/integration/agent/private-map-entry-intrinsics.test.ts b/tests/integration/agent/private-map-entry-intrinsics.test.ts new file mode 100644 index 0000000000..e2a6e10716 --- /dev/null +++ b/tests/integration/agent/private-map-entry-intrinsics.test.ts @@ -0,0 +1,53 @@ +import type { StreamingToolCall } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { shouldContinueAfterStreamStep } from "#veryfront/agent/runtime/tool-result-continuation.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private map entry ${hooks ? "hooks" : "baseline"}`, () => { + it("continues after completed provider results without exposing tool-call entries", () => { + const marker = "synthetic-private-provider-arguments"; + const toolCalls = createPrivateMap(); + toolCalls.set("call", { + id: "call", + name: "inspect", + arguments: JSON.stringify({ text: marker }), + inputAvailable: true, + providerExecuted: true, + }); + const state = { + accumulatedText: "", + finishReason: "stop", + toolCalls, + toolResults: [{ + toolCallId: "call", + toolName: "inspect", + output: { ok: true }, + providerExecuted: true, + }], + }; + const iterator = Array.prototype[Symbol.iterator]; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let continued = false; + try { + if (hooks) { + Array.prototype[Symbol.iterator] = function () { + if ( + this[0] === "call" && typeof this[1]?.arguments === "string" && + apply(includes, this[1].arguments, [marker]) + ) observations++; + return apply(iterator, this, []); + }; + } + continued = shouldContinueAfterStreamStep(state); + } finally { + if (hooks) Array.prototype[Symbol.iterator] = iterator; + } + assertEquals(continued, true); + assertEquals(observations, 0); + }); + }); +} From 2f8b62ec9d0681892f9eece74593c4d2ff4426af Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 12:55:46 +0200 Subject: [PATCH 149/194] fix(agent): protect security validation buffer appends --- src/agent/middleware/security/validator.ts | 61 +++++++++-------- .../validation-buffer-intrinsics.test.ts | 66 +++++++++++++++++++ 2 files changed, 99 insertions(+), 28 deletions(-) create mode 100644 tests/integration/agent/validation-buffer-intrinsics.test.ts diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index c28b4afe1d..972c598374 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -2,6 +2,7 @@ import { privateTextTrim, privateTextTrimStart } from "#veryfront/security/priva import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { + appendPrivateArray, concatPrivateArrays, everyPrivateArray, filterPrivateArray, @@ -240,7 +241,7 @@ export class InputValidator { const maxLength = options?.checkMaxLength === false ? undefined : this.config.maxLength; if (maxLength != null && input.length > maxLength) { - violations.push({ + pushPrivateArray(violations, { type: "input", reason: `Input exceeds maximum length of ${maxLength}`, content: `${input.substring(0, 100)}...`, @@ -253,7 +254,7 @@ export class InputValidator { // cannot skip a repeat match and caller-owned patterns remain untouched. if (!testBlockedPattern(pattern, input)) continue; - violations.push({ + pushPrivateArray(violations, { type: "input", reason: "Input matches blocked pattern", content: input, @@ -267,7 +268,7 @@ export class InputValidator { if (customValidate) { const customValid = await customValidate(input); if (!customValid) { - violations.push({ + pushPrivateArray(violations, { type: "input", reason: "Custom validation failed", content: input, @@ -339,7 +340,7 @@ export class OutputFilter { // lastIndex across calls or require caller-owned regexes to be mutable. if (!testBlockedPattern(pattern, filtered)) continue; - violations.push({ + pushPrivateArray(violations, { type: "output", reason: "Output contains blocked pattern", content: filtered, @@ -398,12 +399,12 @@ async function filterStructuredOutputValue( } if (Array.isArray(value)) { - const filteredItems = []; + const filteredItems: unknown[] = []; const violations: SecurityViolation[] = []; for (const item of value) { const result = await filterStructuredOutputValue(item, outputFilter); - filteredItems.push(result.value); - violations.push(...result.violations); + pushPrivateArray(filteredItems, result.value); + appendPrivateArray(violations, result.violations); } return { value: filteredItems, violations }; } @@ -414,7 +415,7 @@ async function filterStructuredOutputValue( for (const [key, item] of Object.entries(value)) { const result = await filterStructuredOutputValue(item, outputFilter); filteredObject[key] = result.value; - violations.push(...result.violations); + appendPrivateArray(violations, result.violations); } return { value: filteredObject, violations }; } @@ -431,12 +432,12 @@ function extractPartInputText(part: unknown): string[] { if (!isRecord(part) || part.type === "tool-result") return []; const values: string[] = []; - if (typeof part.inputText === "string") values.push(part.inputText); + if (typeof part.inputText === "string") pushPrivateArray(values, part.inputText); const appendSerialized = (value: unknown) => { if (!isRecord(value)) return; try { const serialized = JSON.stringify(value); - if (typeof serialized === "string") values.push(serialized); + if (typeof serialized === "string") pushPrivateArray(values, serialized); } catch { // Provider converters ignore non-text input on caller-authored user and // system messages. Unsupported JSON values must not fail the turn here. @@ -527,7 +528,7 @@ function extractMessageAssembledTextsRegardlessOfRole(message: Message): string[ ...attachmentMetadata, ]; if (message.role === "user" && buildAttachmentContextFromParts(message.parts)) { - assembled.push(getUserTextWithAttachmentContext(message.parts)); + pushPrivateArray(assembled, getUserTextWithAttachmentContext(message.parts)); } return assembled; } @@ -596,7 +597,7 @@ function extractAdjacentRuns( const flushRun = () => { // Runs of a single message are already covered by the per-message // extraction, including its own assembled forms. - if (run.length > 1) runs.push(run); + if (run.length > 1) pushPrivateArray(runs, run); run = []; }; @@ -623,7 +624,7 @@ function extractAdjacentRuns( ) { continue; } - run.push(message); + pushPrivateArray(run, message); } flushRun(); @@ -671,7 +672,7 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { const alreadyCovered = somePrivateArray(runs, (candidate) => candidate.length === run.length && everyPrivateArray(candidate, (message, index) => message === run[index])); - if (!alreadyCovered) runs.push(run); + if (!alreadyCovered) pushPrivateArray(runs, run); } // Anthropic retains whitespace-only system layers and joins each layer with @@ -840,10 +841,10 @@ function collapseTextParts(parts: Message["parts"], text: string | undefined): M let replaced = false; for (const part of parts) { if (!isTextPart(part)) { - collapsed.push(part); + pushPrivateArray(collapsed, part); } else if (!replaced && text !== undefined) { replaced = true; - collapsed.push(copySanitizedTextPart(part, text)); + pushPrivateArray(collapsed, copySanitizedTextPart(part, text)); } } return collapsed; @@ -1181,8 +1182,12 @@ function createTrustedMatchPredicate( const escaped = body[++index]; if (depth === 0 && (escaped === "b" || escaped === "B")) { const choices = [character + escaped + excludePosition(lower) + excludePosition(upper)]; - if (word(segment.text[0]) === (escaped === "b")) choices.push(atPosition(lower)); - if (word(segment.text.at(-1)) === (escaped === "b")) choices.push(atPosition(upper)); + if (word(segment.text[0]) === (escaped === "b")) { + pushPrivateArray(choices, atPosition(lower)); + } + if (word(segment.text.at(-1)) === (escaped === "b")) { + pushPrivateArray(choices, atPosition(upper)); + } result += "(?:" + joinPrivateArray(choices, "|") + ")"; } else result += character + escaped; continue; @@ -1198,12 +1203,12 @@ function createTrustedMatchPredicate( const assertion = /^\(\?(]+>/.exec(body.slice(index)); if (body[index + 1] !== "?" || named) { result += "(?:"; @@ -1290,18 +1295,18 @@ function createTrustedMatchPredicate( continue; } if (pattern.source.startsWith("(?=", index)) { - groups.push({ kind: "ahead", ignoreCase, bodyStart: index + 3 }); + pushPrivateArray(groups, { kind: "ahead", ignoreCase, bodyStart: index + 3 }); source += "(?=(?:"; index += 2; continue; } if (pattern.source.startsWith("(?<=", index)) { - groups.push({ kind: "behind", ignoreCase, bodyStart: index + 4 }); + pushPrivateArray(groups, { kind: "behind", ignoreCase, bodyStart: index + 4 }); source += "(?<=" + lowerGuard + "(?:"; index += 3; continue; } - groups.push({ kind: "ordinary", ignoreCase }); + pushPrivateArray(groups, { kind: "ordinary", ignoreCase }); const modifiers = /^\(\?([ims]*)(?:-([ims]+))?:/.exec(pattern.source.slice(index)); if (modifiers?.[1]?.includes("i")) ignoreCase = true; if (modifiers?.[2]?.includes("i")) ignoreCase = false; @@ -1364,7 +1369,7 @@ async function assertProviderRunsValid( for (const violation of validation.violations) { const pattern = violation.pattern; if (pattern === undefined) { - introducedViolations.push(violation); + pushPrivateArray(introducedViolations, violation); continue; } const trustedMatches = flatMapPrivateArray( @@ -1389,7 +1394,7 @@ async function assertProviderRunsValid( (trusted) => trusted.index === match.index && trusted.text === match.text, ), ); - if (introduced) introducedViolations.push(violation); + if (introduced) pushPrivateArray(introducedViolations, violation); } } if (introducedViolations.length > 0) { @@ -1433,7 +1438,7 @@ function patternOccurrences(input: string, pattern: RegExp): { index: number; te match; match = applyRegExp(regexpExec, matcher, [input]) as RegExpExecArray | null ) { - matches.push({ index: match.index, text: match[0] }); + pushPrivateArray(matches, { index: match.index, text: match[0] }); if (match[0].length === 0) { matcher.lastIndex = advanceStringIndex( input, @@ -1589,11 +1594,11 @@ export function securityMiddleware( if (kind !== "current") { const previous = assembled.trustedSegments.at(-1); if (previous && kind === previousKind) previous.text += runSeparator + text; - else assembled.trustedSegments.push({ start, text }); + else pushPrivateArray(assembled.trustedSegments, { start, text }); } previousKind = kind; } - providerRuns.push(assembled); + pushPrivateArray(providerRuns, assembled); } } } diff --git a/tests/integration/agent/validation-buffer-intrinsics.test.ts b/tests/integration/agent/validation-buffer-intrinsics.test.ts new file mode 100644 index 0000000000..4880d23782 --- /dev/null +++ b/tests/integration/agent/validation-buffer-intrinsics.test.ts @@ -0,0 +1,66 @@ +import "#veryfront/schemas/_test-setup.ts"; +import type { AgentContext, Message } from "#veryfront/agent/types.ts"; +import { + InputValidator, + OutputFilter, + securityMiddleware, +} from "#veryfront/agent/middleware/security/validator.ts"; +import { getTurnMessageValidator } from "#veryfront/agent/middleware/turn-validation.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private validation buffers ${hooks ? "hooks" : "baseline"}`, () => { + it("validates adjacent messages and violations without exposing appended content", async () => { + const marker = "synthetic-private-validation-content"; + const messages: Message[] = [ + { id: "s1", role: "system", parts: [{ type: "text", text: marker }] }, + { id: "s2", role: "system", parts: [{ type: "text", text: "instructions" }] }, + { id: "u1", role: "user", parts: [{ type: "text", text: marker }] }, + { id: "u2", role: "user", parts: [{ type: "text", text: "question" }] }, + ]; + const context: AgentContext = { + agentId: "synthetic", + input: messages, + model: "hosted/synthetic", + data: {}, + platform: {}, + }; + const push = Array.prototype.push; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const inputValidator = new InputValidator({ blockedPatterns: [/synthetic-private/] }); + const outputFilter = new OutputFilter({ blockedPatterns: [/synthetic-private/] }); + let observations = 0; + let completed = false; + let inputViolations = 0; + let outputViolations = 0; + try { + if (hooks) { + Array.prototype.push = function (...items) { + for (let index = 0; index < items.length; index++) { + const serialized = stringify(items[index]); + if (serialized && apply(includes, serialized, [marker])) observations++; + } + return apply(push, this, items); + }; + } + const response = await securityMiddleware({ input: {} })( + context, + () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), + ); + await getTurnMessageValidator(context)!([], messages); + inputViolations = (await inputValidator.validate(marker)).violations.length; + outputViolations = (await outputFilter.filter(marker)).violations.length; + completed = response.status === "completed"; + } finally { + if (hooks) Array.prototype.push = push; + } + assertEquals(completed, true); + assertEquals(inputViolations, 1); + assertEquals(outputViolations, 1); + assertEquals(observations, 0); + }); + }); +} From 3697301285a073de30f07b789f7bb6292010923d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 13:05:59 +0200 Subject: [PATCH 150/194] fix(agent): protect step results and timed provider reads --- src/agent/runtime/chat-stream-handler.ts | 13 ++-- src/agent/runtime/index.ts | 3 +- .../agent/step-result-map-intrinsics.test.ts | 62 +++++++++++++++++++ .../agent/stream-read-race-intrinsics.test.ts | 49 +++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 tests/integration/agent/step-result-map-intrinsics.test.ts create mode 100644 tests/integration/agent/stream-read-race-intrinsics.test.ts diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 57a8ced2f4..38da71852f 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -1,5 +1,6 @@ import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { chainPrivatePromise, createPrivateDeferred } from "#veryfront/security/private-promise.ts"; import { pushPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; import { @@ -520,13 +521,15 @@ async function readNextStreamPartWithTimeout( abortSignal?: AbortSignal, ): Promise | "timeout"> { let timeoutId: ReturnType | undefined; + const pending = createPrivateDeferred | "timeout">(); try { - return await Promise.race([ + void chainPrivatePromise( readNextStreamPart(iterator, state, abortSignal), - new Promise<"timeout">((resolve) => { - timeoutId = setTimeoutFn(() => resolve("timeout"), timeoutMs); - }), - ]); + pending.resolve, + pending.reject, + ); + timeoutId = setTimeoutFn(() => pending.resolve("timeout"), timeoutMs); + return await pending.promise; } finally { if (timeoutId !== undefined) { clearTimeoutFn(timeoutId); diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 67dc671582..8f113ad674 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -34,6 +34,7 @@ import { } from "#veryfront/security/private-stream.ts"; import { chainPrivatePromise, createPrivateDeferred } from "#veryfront/security/private-promise.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { enterSerializedTurn, withRuntimeTurnLineage, @@ -3462,7 +3463,7 @@ export class AgentRuntime { for (let step = 0; step < maxSteps; step++) { throwIfAborted(abortSignal); sendSSE(controller, encoder, { type: "step-start" }); - const currentStepToolResults = new Map(); + const currentStepToolResults = createPrivateMap(); const stepRuntimeContext = skillState.hasSubmittedFormInput ? markSubmittedFormInputRuntimeContext(currentRuntimeContext) : currentRuntimeContext; diff --git a/tests/integration/agent/step-result-map-intrinsics.test.ts b/tests/integration/agent/step-result-map-intrinsics.test.ts new file mode 100644 index 0000000000..d01290b534 --- /dev/null +++ b/tests/integration/agent/step-result-map-intrinsics.test.ts @@ -0,0 +1,62 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { tool } from "#veryfront/tool"; + +for (const hooks of [false, true]) { + describe(`private step result map ${hooks ? "hooks" : "baseline"}`, () => { + it("retains tool output without passing it through a replaced Map constructor", async () => { + const marker = "synthetic-private-step-result"; + const model = scriptedModel([ + { toolCalls: [{ id: "call", name: "inspect", input: {} }] }, + { text: "Complete" }, + ], { only: "stream" }); + let executions = 0; + const runtime = createEphemeralAgentWithRuntimeOptions({ + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic tool instructions", + maxSteps: 2, + tools: { + inspect: tool({ + id: "inspect", + description: "Synthetic inspection", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + executions++; + return Promise.resolve({ text: marker }); + }, + }), + }, + }, { resolveModelRuntime: () => model }); + const NativeMap = Map; + const nativeSet = Map.prototype.set; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let output = ""; + try { + if (hooks) { + globalThis.Map = class extends NativeMap { + override set(key: K, value: V): this { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + apply(nativeSet, this, [key, value]); + return this; + } + }; + } + const result = await runtime.stream({ input: "Synthetic request" }); + output = await result.toDataStreamResponse().text(); + } finally { + if (hooks) globalThis.Map = NativeMap; + } + assertEquals(executions, 1); + assertEquals(model.callCount, 2); + assertStringIncludes(output, marker); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/stream-read-race-intrinsics.test.ts b/tests/integration/agent/stream-read-race-intrinsics.test.ts new file mode 100644 index 0000000000..fc907bbc32 --- /dev/null +++ b/tests/integration/agent/stream-read-race-intrinsics.test.ts @@ -0,0 +1,49 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createMockResult, + createSSECollector, +} from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; +import { createStreamState, processStream } from "#veryfront/agent/runtime/chat-stream-handler.ts"; + +for (const hooks of [false, true]) { + describe(`private stream read race ${hooks ? "hooks" : "baseline"}`, () => { + it("reads provider parts without exposing their promises to Promise.race", async () => { + const marker = "synthetic-private-timed-read"; + const state = createStreamState(); + const { controller, encoder } = createSSECollector(); + const result = createMockResult([ + { type: "text-delta", text: marker }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]); + const NativePromise = Promise; + const race = Promise.race; + const then = Promise.prototype.then; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + try { + if (hooks) { + Promise.race = ((values: Iterable) => { + for (const value of values) { + apply(then, value, [(part: unknown) => { + if (apply(includes, stringify(part) ?? "", [marker])) observations++; + }, () => undefined]); + } + return apply(race, NativePromise, [values]); + }) as typeof race; + } + await processStream(result, state, controller, encoder, "text", { + streamIdleTimeoutMs: 1_000, + }); + } finally { + if (hooks) Promise.race = race; + } + assertEquals(state.accumulatedText, marker); + assertEquals(state.finishReason, "stop"); + assertEquals(observations, 0); + }); + }); +} From e89f581c857d00ea7fc453d6f8d31b043ca2ded3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 13:22:07 +0200 Subject: [PATCH 151/194] fix(agent): protect provider replay and instruction traversal --- src/agent/runtime/message-adapter.test.ts | 18 ++++ src/agent/runtime/message-adapter.ts | 68 +++++++++---- src/agent/runtime/tool-inventory.test.ts | 12 +++ src/agent/runtime/tool-inventory.ts | 81 +++++++++++----- src/chat/provider-message-content.test.ts | 18 ++++ src/chat/provider-message-content.ts | 18 ++-- src/security/private-text.ts | 10 ++ .../provider-replay-input-intrinsics.test.ts | 97 +++++++++++++++++++ 8 files changed, 270 insertions(+), 52 deletions(-) create mode 100644 src/chat/provider-message-content.test.ts create mode 100644 tests/integration/agent/provider-replay-input-intrinsics.test.ts diff --git a/src/agent/runtime/message-adapter.test.ts b/src/agent/runtime/message-adapter.test.ts index d71835557d..520b58bf78 100644 --- a/src/agent/runtime/message-adapter.test.ts +++ b/src/agent/runtime/message-adapter.test.ts @@ -96,6 +96,24 @@ function agentRuntimeToolResultPart(result: unknown): AgentRuntimeMessagePart { } describe("agent runtime message adapter", () => { + it("replays private messages without consulting supplied array iterators", () => { + const parts = [{ type: "text" as const, text: "synthetic private replay" }]; + const messages = [{ role: "assistant" as const, parts }]; + let observations = 0; + for (const values of [messages, parts]) { + Object.defineProperty(values, Symbol.iterator, { + value: function (this: unknown[]) { + observations++; + return Reflect.apply(Array.prototype[Symbol.iterator], this, []); + }, + }); + } + assertEquals(convertAgentRuntimeMessagesToProviderMessages(messages), [{ + role: "assistant", + content: [{ type: "text", text: "synthetic private replay" }], + }]); + assertEquals(observations, 0); + }); it("converts text, tool-call, and tool-result provider model messages into agent runtime messages", () => { const agentRuntimeMessages = convertProviderMessagesToAgentRuntimeMessages([ providerMessage({ role: "system", content: "System instructions" }), diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index 09c17646e9..c3855d07db 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -1,8 +1,18 @@ import { appendPrivateArray, + concatPrivateArrays, + joinPrivateArray, mapPrivateArray, pushPrivateArray, + somePrivateArray, } from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { + privateTextIncludes, + privateTextStartsWith, + privateTextTrim, +} from "#veryfront/security/private-text.ts"; import { privateJsonStringify } from "#veryfront/security/private-json.ts"; import { getProviderModelMessageSourceId, isRecord } from "#veryfront/chat/conversation.ts"; import { @@ -17,6 +27,8 @@ import { } from "../../chat/types.ts"; import { toChildRunToolInputRecord } from "../child-run/execution-support.ts"; +const hasOwn = Object.hasOwn; + type StructuredProviderPart = Exclude[number]; type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; @@ -149,7 +161,7 @@ export class AgentRuntimeMessageConversionError extends Error { } function hasTextContent(text: string): boolean { - return text.trim().length > 0; + return privateTextTrim(text).length > 0; } function getOptionalStringField(part: unknown, key: string): string | undefined { @@ -254,7 +266,7 @@ function createAttachmentReference(part: StructuredProviderPart): UploadedFileRe return null; } - const normalizedUrl = url?.startsWith("data:") ? undefined : url; + const normalizedUrl = url !== undefined && privateTextStartsWith(url, "data:") ? undefined : url; return { name: filename ?? (part.type === "image" ? "image" : "file"), @@ -281,7 +293,10 @@ function buildAttachmentContextPart( } function hasUploadedFilesAnnotation(parts: StructuredProviderPart[]): boolean { - return parts.some((part) => part.type === "text" && part.text.includes("")); + return somePrivateArray( + parts, + (part) => part.type === "text" && privateTextIncludes(part.text, ""), + ); } function convertContentToAgentRuntimeParts( @@ -295,7 +310,9 @@ function convertContentToAgentRuntimeParts( const parts: AgentRuntimeMessage["parts"] = []; const attachmentReferences: UploadedFileReference[] = []; - for (const part of message.content) { + for (let index = 0; index < message.content.length; index++) { + if (!hasOwn(message.content, index)) continue; + const part = message.content[index]!; const convertedPart = convertStructuredPart(part); if (convertedPart) { pushPrivateArray(parts, convertedPart); @@ -383,7 +400,10 @@ export function getAgentRuntimeToolCallPart( return null; } - if (part.type !== "tool_call" && part.type !== "tool-call" && !part.type.startsWith("tool-")) { + if ( + part.type !== "tool_call" && part.type !== "tool-call" && + !privateTextStartsWith(part.type, "tool-") + ) { return null; } @@ -426,11 +446,7 @@ export function getAgentRuntimeToolResultPart( return { toolCallId, toolName, - output: Object.hasOwn(part, "result") - ? part.result - : Object.hasOwn(part, "output") - ? part.output - : null, + output: hasOwn(part, "result") ? part.result : hasOwn(part, "output") ? part.output : null, }; } @@ -458,7 +474,7 @@ export function createToolResultPart(part: { } function joinTextParts(textParts: readonly ProviderTextPart[]): string { - return mapPrivateArray(textParts, (part) => part.text).join("\n\n"); + return joinPrivateArray(mapPrivateArray(textParts, (part) => part.text), "\n\n"); } function collectAgentRuntimeProviderContentParts( @@ -469,9 +485,11 @@ function collectAgentRuntimeProviderContentParts( const toolCallParts: ProviderToolCallPart[] = []; const toolResultParts: ChatToolResultPart[] = []; const fileParts: ChatModelFilePart[] = []; - const toolNamesById = new Map(); + const toolNamesById = createPrivateMap(); - for (const part of parts) { + for (let index = 0; index < parts.length; index++) { + if (!hasOwn(parts, index)) continue; + const part = parts[index]!; if (part.type === "source-url" || part.type === "source-document") { continue; } @@ -530,8 +548,8 @@ function convertAssistantAgentRuntimePartsToProviderMessages( ProviderReasoningPart | ProviderTextPart | ProviderToolCallPart > = []; const toolResults: ChatToolResultPart[] = []; - const pendingToolCallIds = new Set(); - const toolNamesById = new Map(); + const pendingToolCallIds = createPrivateSet(); + const toolNamesById = createPrivateMap(); const providerMessages: ProviderModelMessage[] = []; const flushAssistantMessage = ( @@ -541,7 +559,10 @@ function convertAssistantAgentRuntimePartsToProviderMessages( return; } - pushPrivateArray(providerMessages, { role: "assistant", content: [...content] }); + pushPrivateArray(providerMessages, { + role: "assistant", + content: mapPrivateArray(content, (part) => part), + }); content.length = 0; }; @@ -550,7 +571,10 @@ function convertAssistantAgentRuntimePartsToProviderMessages( return; } - pushPrivateArray(providerMessages, { role: "tool", content: [...toolResults] }); + pushPrivateArray(providerMessages, { + role: "tool", + content: mapPrivateArray(toolResults, (part) => part), + }); toolResults.length = 0; }; @@ -589,7 +613,9 @@ function convertAssistantAgentRuntimePartsToProviderMessages( pendingToolCallIds.delete(part.toolCallId); }; - for (const part of parts) { + for (let index = 0; index < parts.length; index++) { + if (!hasOwn(parts, index)) continue; + const part = parts[index]!; if (part.type === "source-url" || part.type === "source-document") { continue; } @@ -670,7 +696,7 @@ function createProviderMessagesFromAgentRuntimeMessage( }]; } - const content: ChatUserContentPart[] = [...textParts, ...fileParts]; + const content = concatPrivateArrays(textParts, fileParts); return [{ role: "user", content, @@ -719,7 +745,9 @@ export function convertAgentRuntimeMessagesToProviderMessages( ): ProviderModelMessage[] { const converted: ProviderModelMessage[] = []; - for (const message of messages) { + for (let index = 0; index < messages.length; index++) { + if (!hasOwn(messages, index)) continue; + const message = messages[index]!; appendPrivateArray(converted, createProviderMessagesFromAgentRuntimeMessage(message)); } diff --git a/src/agent/runtime/tool-inventory.test.ts b/src/agent/runtime/tool-inventory.test.ts index ae7f0e351f..cda1c4666b 100644 --- a/src/agent/runtime/tool-inventory.test.ts +++ b/src/agent/runtime/tool-inventory.test.ts @@ -9,6 +9,18 @@ import { } from "./tool-inventory.ts"; describe("runtime tool inventory instructions", () => { + it("flattens private instructions without calling a supplied map override", () => { + const instructions = [{ role: "system" as const, content: " synthetic instructions " }]; + let observations = 0; + Object.defineProperty(instructions, "map", { + value: function (this: typeof instructions, ...args: unknown[]) { + observations++; + return Reflect.apply(Array.prototype.map, this, args); + }, + }); + assertEquals(flattenSystemInstructions(instructions), "synthetic instructions"); + assertEquals(observations, 0); + }); it("appends visible tool inventory to string instructions", () => { assertEquals(withRuntimeToolInventory("Base system", ["write_file", "read_file"]), [ { role: "system", content: "Base system" }, diff --git a/src/agent/runtime/tool-inventory.ts b/src/agent/runtime/tool-inventory.ts index 6c2e341a7b..807386e8d9 100644 --- a/src/agent/runtime/tool-inventory.ts +++ b/src/agent/runtime/tool-inventory.ts @@ -1,4 +1,19 @@ import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; +import { + concatPrivateArrays, + filterPrivateArray, + joinPrivateArray, + mapPrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { + privateTextEndsWith, + privateTextLastIndexOf, + privateTextSlice, + privateTextStartsWith, + privateTextTrim, + privateTextTrimEnd, +} from "#veryfront/security/private-text.ts"; const RUNTIME_TOOL_INVENTORY_HEADER = "Current run tool inventory:"; const RUNTIME_TOOL_INVENTORY_FOOTER = @@ -20,7 +35,7 @@ export interface DeferredToolSummary { } function getRuntimeToolInventoryFooter(toolNames: readonly string[]): string { - return toolNames.includes("tool_search") + return somePrivateArray(toolNames, (name) => name === "tool_search") ? `${RUNTIME_TOOL_INVENTORY_FOOTER}\n${RUNTIME_TOOL_SEARCH_GUIDANCE}` : RUNTIME_TOOL_INVENTORY_FOOTER; } @@ -35,13 +50,16 @@ function getRuntimeToolInventoryFooter(toolNames: readonly string[]): string { * the whole reason an unloaded tool is worth mentioning at all. */ function createDeferredToolSection(deferredTools: readonly DeferredToolSummary[]): string { - const entries = deferredTools - .map((tool) => - tool.description === undefined || tool.description.length === 0 - ? `- ${tool.name}` - : `- ${tool.name}: ${tool.description}` - ) - .join("\n"); + const entries = joinPrivateArray( + mapPrivateArray( + deferredTools, + (tool) => + tool.description === undefined || tool.description.length === 0 + ? `- ${tool.name}` + : `- ${tool.name}: ${tool.description}`, + ), + "\n", + ); return `\n\n${RUNTIME_DEFERRED_TOOL_HEADER} @@ -55,7 +73,7 @@ function createRuntimeToolInventoryMessage( deferredTools: readonly DeferredToolSummary[], ): ChatSystemMessage { const toolList = toolNames.length > 0 - ? toolNames.map((toolName) => `- ${toolName}`).join("\n") + ? joinPrivateArray(mapPrivateArray(toolNames, (toolName) => `- ${toolName}`), "\n") : "- none"; const deferredSection = deferredTools.length > 0 ? createDeferredToolSection(deferredTools) : ""; @@ -70,23 +88,29 @@ ${getRuntimeToolInventoryFooter(toolNames)}${deferredSection}`, } function removeFlattenedRuntimeToolInventory(instructions: string): string { - const headerIndex = instructions.lastIndexOf(RUNTIME_TOOL_INVENTORY_HEADER); + const headerIndex = privateTextLastIndexOf(instructions, RUNTIME_TOOL_INVENTORY_HEADER); if (headerIndex < 0) { return instructions; } - const inventory = instructions.slice(headerIndex); + const inventory = privateTextSlice(instructions, headerIndex); // A deferred section, when present, is the last thing written, so it carries // the terminator. Missing it here would leave the previous inventory in place // and append a second one on the next step. - const terminatesInventory = inventory.endsWith(RUNTIME_TOOL_INVENTORY_FOOTER) || - inventory.endsWith(`${RUNTIME_TOOL_INVENTORY_FOOTER}\n${RUNTIME_TOOL_SEARCH_GUIDANCE}`) || - inventory.endsWith(RUNTIME_DEFERRED_TOOL_FOOTER); - if (!inventory.startsWith(`${RUNTIME_TOOL_INVENTORY_HEADER}\n\n- `) || !terminatesInventory) { + const terminatesInventory = privateTextEndsWith(inventory, RUNTIME_TOOL_INVENTORY_FOOTER) || + privateTextEndsWith( + inventory, + `${RUNTIME_TOOL_INVENTORY_FOOTER}\n${RUNTIME_TOOL_SEARCH_GUIDANCE}`, + ) || + privateTextEndsWith(inventory, RUNTIME_DEFERRED_TOOL_FOOTER); + if ( + !privateTextStartsWith(inventory, `${RUNTIME_TOOL_INVENTORY_HEADER}\n\n- `) || + !terminatesInventory + ) { return instructions; } - return instructions.slice(0, headerIndex).trimEnd(); + return privateTextTrimEnd(privateTextSlice(instructions, 0, headerIndex)); } function removeStructuredRuntimeToolInventory( @@ -105,8 +129,9 @@ export function hasRuntimeToolInventory( ): boolean { return typeof instructions === "string" ? removeFlattenedRuntimeToolInventory(instructions) !== instructions - : instructions.some((message) => - removeFlattenedRuntimeToolInventory(message.content) !== message.content + : somePrivateArray( + instructions, + (message) => removeFlattenedRuntimeToolInventory(message.content) !== message.content, ); } @@ -131,16 +156,20 @@ export function withRuntimeToolInventory( : [inventoryMessage]; } - const baseInstructions = instructions - .map(removeStructuredRuntimeToolInventory) - .filter((message): message is ChatSystemMessage => message !== undefined); - return [...baseInstructions, inventoryMessage]; + const baseInstructions = filterPrivateArray( + mapPrivateArray(instructions, removeStructuredRuntimeToolInventory), + (message): message is ChatSystemMessage => message !== undefined, + ); + return concatPrivateArrays(baseInstructions, [inventoryMessage]); } /** Flatten system instructions helper. */ export function flattenSystemInstructions(instructions: readonly ChatSystemMessage[]): string { - return instructions - .map((message) => message.content.trim()) - .filter((content) => content.length > 0) - .join("\n\n"); + return joinPrivateArray( + filterPrivateArray( + mapPrivateArray(instructions, (message) => privateTextTrim(message.content)), + (content) => content.length > 0, + ), + "\n\n", + ); } diff --git a/src/chat/provider-message-content.test.ts b/src/chat/provider-message-content.test.ts new file mode 100644 index 0000000000..b9e73a57e1 --- /dev/null +++ b/src/chat/provider-message-content.test.ts @@ -0,0 +1,18 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { cleanContent } from "./provider-message-content.ts"; + +describe("provider message content", () => { + it("checks private content without invoking its own some override", () => { + const content = [{ type: "text", text: "synthetic private provider content" }]; + let observations = 0; + Object.defineProperty(content, "some", { + value: function (this: typeof content, ...args: Parameters) { + observations++; + return Reflect.apply(Array.prototype.some, this, args); + }, + }); + assertEquals(cleanContent(content, "user"), [{ type: "text", text: content[0]!.text }]); + assertEquals(observations, 0); + }); +}); diff --git a/src/chat/provider-message-content.ts b/src/chat/provider-message-content.ts index 06a530826a..f2d2a4fa0f 100644 --- a/src/chat/provider-message-content.ts +++ b/src/chat/provider-message-content.ts @@ -1,9 +1,12 @@ -import { filterPrivateArray } from "#veryfront/security/private-array.ts"; +import { filterPrivateArray, somePrivateArray } from "#veryfront/security/private-array.ts"; +import { privateTextStartsWith, privateTextTrim } from "#veryfront/security/private-text.ts"; import { isRecord } from "./part-field-access.ts"; import type { ProviderModelMessage } from "./types.ts"; +const isArray = Array.isArray; + function hasNonEmptyStringField(record: Record, key: string): boolean { - return typeof record[key] === "string" && record[key].trim().length > 0; + return typeof record[key] === "string" && privateTextTrim(record[key]).length > 0; } function hasValidToolResultOutput(value: unknown): boolean { @@ -64,7 +67,7 @@ function isKeepableModelPart( } const url = typeof part.url === "string" ? part.url : ""; - if (url.startsWith("data:image/") && part.filename === "preview-screenshot.png") { + if (privateTextStartsWith(url, "data:image/") && part.filename === "preview-screenshot.png") { return false; } return true; @@ -79,14 +82,17 @@ export function hasValidContent(message: ProviderModelMessage): boolean { if (content === undefined || content === null) return false; if (typeof content === "string") { - return message.role !== "tool" && content.trim().length > 0; + return message.role !== "tool" && privateTextTrim(content).length > 0; } - if (Array.isArray(content)) return cleanContent(content, message.role).length > 0; + if (isArray(content)) return cleanContent(content, message.role).length > 0; return false; } export function cleanContent(content: T[], role: ProviderModelMessage["role"]): T[] { - const hasSubstantiveContent = content.some((part) => isKeepableModelPart(part, role, false)); + const hasSubstantiveContent = somePrivateArray( + content, + (part) => isKeepableModelPart(part, role, false), + ); return filterPrivateArray( content, (part) => isKeepableModelPart(part, role, hasSubstantiveContent), diff --git a/src/security/private-text.ts b/src/security/private-text.ts index 372f826507..01b959c6ad 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -5,6 +5,8 @@ const includes = String.prototype.includes; const endsWith = String.prototype.endsWith; const trimStart = String.prototype.trimStart; const trim = String.prototype.trim; +const trimEnd = String.prototype.trimEnd; +const lastIndexOf = String.prototype.lastIndexOf; const toLowerCase = String.prototype.toLowerCase; export function privateTextToLowerCase(value: string): string { @@ -15,6 +17,14 @@ export function privateTextTrim(value: string): string { return apply(trim, value, []) as string; } +export function privateTextTrimEnd(value: string): string { + return apply(trimEnd, value, []) as string; +} + +export function privateTextLastIndexOf(value: string, search: string): number { + return apply(lastIndexOf, value, [search]) as number; +} + export function privateTextEndsWith(value: string, search: string): boolean { return apply(endsWith, value, [search]) as boolean; } diff --git a/tests/integration/agent/provider-replay-input-intrinsics.test.ts b/tests/integration/agent/provider-replay-input-intrinsics.test.ts new file mode 100644 index 0000000000..b25a454078 --- /dev/null +++ b/tests/integration/agent/provider-replay-input-intrinsics.test.ts @@ -0,0 +1,97 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { cleanContent, hasValidContent } from "#veryfront/chat/provider-message-content.ts"; +import type { ChatUserContentPart, ProviderModelMessage } from "#veryfront/chat/types.ts"; +import { convertAgentRuntimeMessagesToProviderMessages } from "#veryfront/agent/runtime/message-adapter.ts"; +import { + flattenSystemInstructions, + withRuntimeToolInventory, +} from "#veryfront/agent/runtime/tool-inventory.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "array methods", "text methods", "array iterator", "array type"]) { + describe(`private provider replay inputs ${probe}`, () => { + it("preserves provider content, instructions and replay without mutable lookups", () => { + const marker = "synthetic-private-replay-input"; + const content: ChatUserContentPart[] = [ + { type: "text", text: ` ${marker} ` }, + { type: "file", url: `https://example.com/${marker}`, mediaType: "text/plain" }, + ]; + const instructions = [{ role: "system" as const, content: ` ${marker} ` }]; + const replayInput = [{ + role: "assistant" as const, + parts: [ + { type: "text", text: marker }, + { type: "reasoning", text: marker }, + { type: "tool_call", id: "call", name: "inspect", input: { query: marker } }, + { type: "tool_result", tool_call_id: "call", output: { text: marker } }, + { type: "text", text: "Complete" }, + ], + }] satisfies Parameters[0]; + const expectedReplay = convertAgentRuntimeMessagesToProviderMessages(replayInput); + const expectedInstructions = flattenSystemInstructions( + withRuntimeToolInventory(instructions, []), + ); + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; + let observations = 0; + const observe = (value: unknown) => { + try { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + } catch { /* A hook delegates normally for unrelated non-JSON values. */ } + }; + const replace = (target: object, key: PropertyKey, observeArgument = false) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + observe(observeArgument ? args[0] : this); + return apply(descriptor.value, this, args); + }, + }); + }; + let cleaned: unknown; + let validText = false; + let validContent = false; + let flattened = ""; + let inventory = ""; + let replay: ProviderModelMessage[] = []; + try { + if (probe === "array methods") { + for (const key of ["some", "map", "filter", "join"]) replace(Array.prototype, key); + } else if (probe === "text methods") { + for (const key of ["trim", "trimEnd", "lastIndexOf", "slice", "startsWith", "endsWith"]) { + replace(String.prototype, key); + } + } else if (probe === "array iterator") { + replace(Array.prototype, Symbol.iterator); + } else if (probe === "array type") { + replace(Array, "isArray", true); + } + cleaned = cleanContent(content, "user"); + validText = hasValidContent({ role: "user", content: marker }); + validContent = hasValidContent({ role: "user", content }); + flattened = flattenSystemInstructions(instructions); + const first = withRuntimeToolInventory(instructions, []); + inventory = flattenSystemInstructions(withRuntimeToolInventory(first, [])); + replay = convertAgentRuntimeMessagesToProviderMessages(replayInput); + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(cleaned, content); + assertEquals(validText, true); + assertEquals(validContent, true); + assertEquals(flattened, marker); + assertEquals(inventory, expectedInstructions); + assertEquals(replay, expectedReplay); + assertEquals(observations, 0); + }); + }); +} From 736744789521a7eaf800ca70eb977cd28c6337fe Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 13:24:22 +0200 Subject: [PATCH 152/194] fix(agent): protect replay tool input and output records --- src/agent/child-run/execution-support.test.ts | 10 ++++++++++ src/agent/child-run/execution-support.ts | 18 ++++++++++++++++-- src/agent/runtime/message-adapter.ts | 19 +++++++++++++++---- .../provider-replay-input-intrinsics.test.ts | 14 +++++++++++++- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/agent/child-run/execution-support.test.ts b/src/agent/child-run/execution-support.test.ts index 9a91ff5469..92e3827a0f 100644 --- a/src/agent/child-run/execution-support.test.ts +++ b/src/agent/child-run/execution-support.test.ts @@ -29,6 +29,16 @@ describe("child-run-execution-support", () => { assertEquals(toChildRunToolInputRecord({ a: 1, b: "two" }), { a: 1, b: "two" }); }); + it("preserves own prototype keys and nested value identity without copying inherited fields", () => { + const nested = { text: "synthetic child input" }; + const input = Object.create({ inherited: true }); + Object.defineProperty(input, "__proto__", { enumerable: true, value: nested }); + const result = toChildRunToolInputRecord(input); + assertEquals(Object.keys(result), ["__proto__"]); + assertEquals(Object.getPrototypeOf(result), Object.prototype); + assertStrictEquals(result["__proto__"], nested); + }); + it("returns an empty record for nullish, array, and primitive inputs", () => { assertEquals(toChildRunToolInputRecord(null), {}); assertEquals(toChildRunToolInputRecord(undefined), {}); diff --git a/src/agent/child-run/execution-support.ts b/src/agent/child-run/execution-support.ts index 17a53fe858..8f592c9b4f 100644 --- a/src/agent/child-run/execution-support.ts +++ b/src/agent/child-run/execution-support.ts @@ -1,13 +1,27 @@ import { isErrorAcrossRealms } from "#veryfront/platform/compat/error-introspection.ts"; import { throwIfAborted } from "#veryfront/utils/abort.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const isArray = Array.isArray; +const objectEntries = Object.entries; /** Record shape for to child run tool input. */ export function toChildRunToolInputRecord(value: unknown): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + if (typeof value !== "object" || value === null || isArray(value)) { return {}; } - return Object.fromEntries(Object.entries(value)); + const result: Record = {}; + const entries = objectEntries(value); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + defineOwnDataProperty(result, entry[0], entry[1], { + enumerable: true, + configurable: true, + writable: true, + }); + } + return result; } /** diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index c3855d07db..6dc0582218 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -8,6 +8,7 @@ import { } from "#veryfront/security/private-array.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { privateTextIncludes, privateTextStartsWith, @@ -28,6 +29,8 @@ import { import { toChildRunToolInputRecord } from "../child-run/execution-support.ts"; const hasOwn = Object.hasOwn; +const isArray = Array.isArray; +const objectEntries = Object.entries; type StructuredProviderPart = Exclude[number]; @@ -346,14 +349,22 @@ function toJsonValue(value: unknown): JsonValue { return value; } - if (Array.isArray(value)) { + if (isArray(value)) { return mapPrivateArray(value, (item) => toJsonValue(item)); } if (isRecord(value)) { - return Object.fromEntries( - mapPrivateArray(Object.entries(value), ([key, entry]) => [key, toJsonValue(entry)]), - ); + const result: Record = {}; + const entries = objectEntries(value); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + defineOwnDataProperty(result, entry[0], toJsonValue(entry[1]), { + enumerable: true, + configurable: true, + writable: true, + }); + } + return result; } return privateJsonStringify(value); diff --git a/tests/integration/agent/provider-replay-input-intrinsics.test.ts b/tests/integration/agent/provider-replay-input-intrinsics.test.ts index b25a454078..0f6165127f 100644 --- a/tests/integration/agent/provider-replay-input-intrinsics.test.ts +++ b/tests/integration/agent/provider-replay-input-intrinsics.test.ts @@ -9,7 +9,16 @@ import { import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -for (const probe of ["baseline", "array methods", "text methods", "array iterator", "array type"]) { +for ( + const probe of [ + "baseline", + "array methods", + "text methods", + "array iterator", + "array type", + "reflection", + ] +) { describe(`private provider replay inputs ${probe}`, () => { it("preserves provider content, instructions and replay without mutable lookups", () => { const marker = "synthetic-private-replay-input"; @@ -71,6 +80,9 @@ for (const probe of ["baseline", "array methods", "text methods", "array iterato replace(Array.prototype, Symbol.iterator); } else if (probe === "array type") { replace(Array, "isArray", true); + } else if (probe === "reflection") { + replace(Object, "entries", true); + replace(Object, "fromEntries", true); } cleaned = cleanContent(content, "user"); validText = hasValidContent({ role: "user", content: marker }); From 5536d79243bf77247137de304856bdea815824cf Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 13:39:27 +0200 Subject: [PATCH 153/194] fix(agent): resolve broker grants from trusted tool catalogs --- docs/guides/agent-service-runtime.md | 6 ++ .../hosted/managed-executor-broker.test.ts | 67 ++++++++++++++++++- src/agent/hosted/managed-executor-broker.ts | 45 ++++++++++--- src/discovery/agent-capability-namespace.ts | 14 ---- src/discovery/agent-scoped-capabilities.ts | 20 ++++-- 5 files changed, 122 insertions(+), 30 deletions(-) delete mode 100644 src/discovery/agent-capability-namespace.ts diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index ebd8c4f42f..f20075829d 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -491,3 +491,9 @@ broker authority before allocating an executor. Preparation uses the narrower br limits and provider-tool list, so its default model requests fit the broker policy. Source IDs must belong to the installed host-facade or remote-source grants. Owner-scoped tool selectors use the same canonical names for capability checks and steering refreshes. + +Trusted ingress must provide `tools.catalog` with the complete tool inventory and ownership metadata, +including project-local tools that have no broker capability. The broker resolves short selectors to +owned tools first, then validates source capabilities against those exact IDs. Preparation and steering +refreshes receive the same resolved grant. A shadowed global tool does not gain authority from an owned +tool's short selector. The catalog must come from trusted source metadata before executor discovery. diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index f25fec7a41..d1070e09b4 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -263,7 +263,7 @@ function fixture( models: new Map([[modelId, { maxOutputTokens: 100, providerTools: [] }]]), }, }, - tools: { sources: new Map(), maxCalls: 4, maxConcurrent: 1 }, + tools: { catalog: new Map(), sources: new Map(), maxCalls: 4, maxConcurrent: 1 }, persistence: {}, state: {}, }; @@ -349,6 +349,7 @@ describe("managed executor broker", () => { } else { if (mismatch === "tool source") f.input.installation.grant.allowedToolNames = ["ungranted"]; else f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; + f.input.tools.catalog = new Map([["ungranted", {}]]); f.input.tools.sources = new Map([["synthetic", { source: { id: "synthetic", @@ -390,6 +391,7 @@ describe("managed executor broker", () => { args: {}, }]; f.input.installation.grant.allowedToolNames = ["inspect"]; + f.input.tools.catalog = new Map([["inspect", {}]]); f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; f.input.tools.sources = new Map([["synthetic", { source: { @@ -424,6 +426,7 @@ describe("managed executor broker", () => { for ( const [agentId, selector, capabilityName, accepted] of [ ["coder", "fetch-paper", "coder--fetch-paper", true], + ["coder", "fetch-paper", "owned-paper", true], ["research.coder", "fetch-paper", "research_coder--fetch-paper", true], ["coder", "fetch-paper", "writer--fetch-paper", false], ["coder", "coder--fetch-paper", "writer--fetch-paper", false], @@ -435,6 +438,21 @@ describe("managed executor broker", () => { f.input.installation.grant.agentId = agentId; f.input.prepare.agentId = agentId; f.input.installation.grant.allowedToolNames = [selector]; + f.input.tools.catalog = new Map([ + ["fetch-paper", {}], + ["coder--fetch-paper", { ownerAgentId: "coder", shortName: "fetch-paper" }], + ["research_coder--fetch-paper", { + ownerAgentId: "research.coder", + shortName: "fetch-paper", + }], + ["writer--fetch-paper", { ownerAgentId: "writer", shortName: "fetch-paper" }], + ]); + if (capabilityName === "owned-paper") { + f.input.tools.catalog = new Map([ + ["fetch-paper", {}], + ["owned-paper", { ownerAgentId: "coder", shortName: "fetch-paper" }], + ]); + } f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; f.input.tools.sources = new Map([["synthetic", { source: { @@ -451,6 +469,7 @@ describe("managed executor broker", () => { if (accepted) { runtime = await broker.start(f.input); assertEquals(f.calls.includes("allocate"), true); + assertEquals(f.installed!.grant.allowedToolNames, [capabilityName]); } else { await assertRejects( async () => { @@ -469,9 +488,48 @@ describe("managed executor broker", () => { }); } + it("rejects a global broker capability shadowed by an owned project-local tool", async () => { + const f = fixture(); + f.input.installation.grant.allowedToolNames = ["fetch-paper"]; + f.input.installation.grant.remoteToolSourceIds = ["synthetic"]; + f.input.tools.catalog = new Map([ + ["fetch-paper", {}], + ["owned-paper", { ownerAgentId: "coder", shortName: "fetch-paper" }], + ]); + f.input.tools.sources = new Map([["synthetic", { + source: { + id: "synthetic", + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ result: "unexpected" }), + }, + allowedToolNames: new Set(["fetch-paper"]), + context: {}, + }]]); + const broker = createManagedExecutorBroker({ maxActive: 1 }); + let runtime: Awaited> | undefined; + try { + await assertRejects( + async () => { + runtime = await broker.start(f.input); + }, + TypeError, + "exceeds the installed", + ); + assertEquals(f.calls, []); + } finally { + await runtime?.close(); + await broker.shutdown(); + await broker.settled; + } + }); + it("uses owner-scoped tool grants for steering refresh authorization", async () => { const f = fixture(); f.input.installation.grant.allowedToolNames = ["fetch-paper"]; + f.input.tools.catalog = new Map([ + ["fetch-paper", {}], + ["coder--fetch-paper", { ownerAgentId: "coder", shortName: "fetch-paper" }], + ]); f.input.installation.capabilities.projectSteering = "steering"; f.input.state.prepareProjectSteering = ({ definition }) => Promise.resolve({ agent: definition }); @@ -492,6 +550,13 @@ describe("managed executor broker", () => { "Refreshed", ); assertEquals(selection, ["coder--fetch-paper"]); + assertEquals(f.installed!.grant.allowedToolNames, ["coder--fetch-paper"]); + await assertRejects(() => + f.peer!.request(executorStateOperations.refreshProjectSteering, { + capabilityId: "steering", + availableToolNames: ["fetch-paper"], + }) + ); await assertRejects(() => f.peer!.request(executorStateOperations.refreshProjectSteering, { capabilityId: "steering", diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index f501aba8ad..e633f89bb1 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -1,4 +1,3 @@ -import { namespaceAgentCapability } from "#veryfront/discovery/agent-capability-namespace.ts"; import type { AgentRunEventSink } from "#veryfront/runtime/model-call-context.ts"; import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; import type { AgentModelRuntimeResolver } from "../runtime/model-transport.ts"; @@ -93,6 +92,8 @@ export interface ManagedExecutorStartInput { runEventSink?: AgentRunEventSink; }; tools: { + /** Complete trusted inventory, including project-local tools, before selector resolution. */ + catalog: ReadonlyMap; sources: ReadonlyMap; maxCalls: number; maxConcurrent: number; @@ -310,7 +311,7 @@ function constrainInstalledOperationGrants( installed.maxOutputTokens = policy.maxOutputTokens; installed.providerToolNames = policy.providerTools.map((tool) => tool.name); } - const allowedTools = installedToolNames(installation); + const allowedTools = installedToolNames(installation, input.tools.catalog); const allowedSources = new Set([ ...installation.grant.hostToolFacadeIds, ...installation.grant.remoteToolSourceIds, @@ -325,16 +326,31 @@ function constrainInstalledOperationGrants( } } } + installation.grant.allowedToolNames = [...allowedTools]; } -function installedToolNames(installation: ExecutorRuntimeInstall): Set { - const allowedTools = new Set(installation.grant.allowedToolNames); - // Trusted source capabilities carry canonical IDs, while an installed selector - // can name a tool relative to the owning agent's namespace. +function installedToolNames( + installation: ExecutorRuntimeInstall, + catalog: ManagedExecutorStartInput["tools"]["catalog"], +): Set { + const allowed = new Set(); + const agentId = installation.grant.agentId; for (const selector of installation.grant.allowedToolNames) { - allowedTools.add(namespaceAgentCapability(installation.grant.agentId, selector)); + let owned: string | undefined; + for (const [id, tool] of catalog) { + if (tool.ownerAgentId !== agentId || tool.shortName !== selector) continue; + if (owned !== undefined && owned !== id) { + throw new TypeError("Managed executor tool catalog has an ambiguous owned selector"); + } + owned = id; + } + const resolved = owned ?? selector; + const tool = catalog.get(resolved); + if (tool && (tool.ownerAgentId === undefined || tool.ownerAgentId === agentId)) { + allowed.add(resolved); + } } - return allowedTools; + return allowed; } function buildBrokerOperations( @@ -377,7 +393,7 @@ function buildBrokerOperations( projectId: execution.projectId, branchId: execution.branchId, ...input.state, - allowedToolNames: [...installedToolNames(installation)], + allowedToolNames: installation.grant.allowedToolNames, }); const combined = new Map(); for (const operations of [model, tools, persistence, state]) { @@ -390,6 +406,16 @@ function buildBrokerOperations( } function snapshotOperationInput(input: ManagedExecutorStartInput): ManagedExecutorOperationInput { + if (input.tools.catalog === undefined) { + throw new TypeError("Managed executor tool catalog is required"); + } + const catalog = new Map([...input.tools.catalog].map(([id, tool]) => [ + id, + Object.freeze({ + ownerAgentId: tool.ownerAgentId, + shortName: tool.shortName, + }), + ])); const sources = new Map(); for (const [id, capability] of input.tools.sources) { const source = capability.source; @@ -423,6 +449,7 @@ function snapshotOperationInput(input: ManagedExecutorStartInput): ManagedExecut ...(input.model.runEventSink ? { runEventSink: input.model.runEventSink } : {}), }, tools: { + catalog, sources, maxCalls: input.tools.maxCalls, maxConcurrent: input.tools.maxConcurrent, diff --git a/src/discovery/agent-capability-namespace.ts b/src/discovery/agent-capability-namespace.ts deleted file mode 100644 index e950fbf1b4..0000000000 --- a/src/discovery/agent-capability-namespace.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** Separator between the agent namespace and the capability short name. */ -export const AGENT_CAPABILITY_NAMESPACE_SEPARATOR = "--"; - -/** Sanitizes an agent id into a provider-safe namespace segment. */ -export function sanitizeCapabilityNamespace(agentId: string): string { - return agentId.replace(/[^A-Za-z0-9_-]/g, "_"); -} - -/** Namespaces a capability short name under its owning agent. */ -export function namespaceAgentCapability(agentId: string, shortName: string): string { - return `${ - sanitizeCapabilityNamespace(agentId) - }${AGENT_CAPABILITY_NAMESPACE_SEPARATOR}${shortName}`; -} diff --git a/src/discovery/agent-scoped-capabilities.ts b/src/discovery/agent-scoped-capabilities.ts index 9a8526cac7..8837aa860d 100644 --- a/src/discovery/agent-scoped-capabilities.ts +++ b/src/discovery/agent-scoped-capabilities.ts @@ -1,9 +1,3 @@ -import { namespaceAgentCapability } from "./agent-capability-namespace.ts"; -export { - AGENT_CAPABILITY_NAMESPACE_SEPARATOR, - namespaceAgentCapability, - sanitizeCapabilityNamespace, -} from "./agent-capability-namespace.ts"; /** * Per-agent colocated capability registration. * @@ -44,6 +38,8 @@ import type { DiscoveryResult, FileDiscoveryContext } from "./types.ts"; const AGENT_TOOLS_SUBDIR = "tools"; const AGENT_SKILLS_SUBDIR = "skills"; +/** Separator between the agent namespace and the capability short name. */ +export const AGENT_CAPABILITY_NAMESPACE_SEPARATOR = "--"; /** Provider tool-call names allow only this charset, max 64 chars. */ const PROVIDER_TOOL_NAME_REGEX = /^[A-Za-z0-9_-]{1,64}$/; @@ -63,6 +59,18 @@ export function isSafePathSegment(name: string): boolean { return name !== "." && name !== ".." && SAFE_PATH_SEGMENT_REGEX.test(name); } +/** Sanitizes an agent id into a provider-safe namespace segment. */ +export function sanitizeCapabilityNamespace(agentId: string): string { + return agentId.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +/** Namespaces a capability short name under its owning agent. */ +export function namespaceAgentCapability(agentId: string, shortName: string): string { + return `${ + sanitizeCapabilityNamespace(agentId) + }${AGENT_CAPABILITY_NAMESPACE_SEPARATOR}${shortName}`; +} + function isTool(value: unknown): value is Tool { return value !== null && typeof value === "object" && typeof (value as Tool).execute === "function"; From e1aa3beccdd9c82907eb31ef361b875d4cb21f33 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 13:40:54 +0200 Subject: [PATCH 154/194] fix(agent): protect optional provider flags and trusted segments --- src/agent/middleware/security/validator.ts | 10 +- ...neration-runtime-message-converter.test.ts | 36 ++++++ ...xt-generation-runtime-message-converter.ts | 11 +- src/chat/provider-message-content.ts | 8 +- ...rovider-optional-fields-intrinsics.test.ts | 110 ++++++++++++++++++ 5 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 tests/integration/agent/provider-optional-fields-intrinsics.test.ts diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 972c598374..c39ae0b91c 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -138,6 +138,7 @@ const PII_REPLACEMENTS: Array<{ pattern: RegExp; label: string }> = [ ]; const RegExpConstructor = RegExp; +const hasOwn = Object.hasOwn; const regexpExec = RegExp.prototype.exec; const regexpReplace = RegExp.prototype[Symbol.replace]; const applyRegExp = Reflect.apply; @@ -1581,7 +1582,9 @@ export function securityMiddleware( for (const runSeparator of ASSEMBLED_TEXT_SEPARATORS) { const assembled: ProviderValidationRun = { text: "", trustedSegments: [] }; let previousKind: "runtime" | "history" | "current" | undefined; - for (const [index, message] of run.entries()) { + for (let index = 0; index < run.length; index++) { + if (!hasOwn(run, index)) continue; + const message = run[index]!; if (index > 0) assembled.text += runSeparator; const start = assembled.text.length; const text = joinPrivateArray(messageTextParts(message), partSeparator); @@ -1592,7 +1595,10 @@ export function securityMiddleware( ? "current" : "history"; if (kind !== "current") { - const previous = assembled.trustedSegments.at(-1); + const last = assembled.trustedSegments.length - 1; + const previous = hasOwn(assembled.trustedSegments, last) + ? assembled.trustedSegments[last] + : undefined; if (previous && kind === previousKind) previous.text += runSeparator + text; else pushPrivateArray(assembled.trustedSegments, { start, text }); } diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 036dcbe526..888453a777 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -17,6 +17,42 @@ import type { Message } from "../types.ts"; import { attachProviderMetadata, markProviderReplayDelivered } from "./provider-metadata.ts"; describe("text-generation-runtime-message-converter", () => { + it("reads provider execution only from own data without invoking optional flag getters", () => { + for (const own of [false, true]) { + let observations = 0; + const part = { + type: "tool-call" as const, + toolCallId: "call", + toolName: "inspect", + args: { text: "synthetic private arguments" }, + }; + const target = own ? part : Object.create(Object.prototype); + Object.defineProperty(target, "providerExecuted", { + get() { + observations++; + return undefined; + }, + }); + if (!own) Object.setPrototypeOf(part, target); + assertEquals( + convertToTextGenerationRuntimeMessages([{ + id: "assistant", + role: "assistant", + parts: [part], + }]), + [{ + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: "call", + toolName: "inspect", + input: part.args, + }], + }], + ); + assertEquals(observations, 0); + } + }); it("compacts completed historical tool rounds without consulting the input reverse scan", () => { const messages: Message[] = [ { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 12c1e121da..a9f973bfe3 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -46,16 +46,18 @@ import { } from "./anthropic-provider-replay-block.ts"; const hasOwn = Object.hasOwn; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const isArray = Array.isArray; function getStringPartField(part: unknown, key: string): string | undefined { - if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; + if (!part || typeof part !== "object" || isArray(part)) return undefined; const value = (part as Record)[key]; return typeof value === "string" && value.length > 0 ? value : undefined; } function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return value !== null && typeof value === "object" && !isArray(value); } function getRecordPartField(part: unknown, key: string): Record | undefined { @@ -66,11 +68,12 @@ function getRecordPartField(part: unknown, key: string): Record } function hasOwnField(part: Record, key: string): boolean { - return Object.hasOwn(part, key); + return hasOwn(part, key); } function isProviderExecutedToolPart(part: Record): boolean { - return part.providerExecuted === true; + const descriptor = getOwnPropertyDescriptor(part, "providerExecuted"); + return descriptor !== undefined && hasOwn(descriptor, "value") && descriptor.value === true; } function getToolCallId(part: unknown): string | undefined { diff --git a/src/chat/provider-message-content.ts b/src/chat/provider-message-content.ts index f2d2a4fa0f..db079ebf37 100644 --- a/src/chat/provider-message-content.ts +++ b/src/chat/provider-message-content.ts @@ -4,6 +4,8 @@ import { isRecord } from "./part-field-access.ts"; import type { ProviderModelMessage } from "./types.ts"; const isArray = Array.isArray; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; function hasNonEmptyStringField(record: Record, key: string): boolean { return typeof record[key] === "string" && privateTextTrim(record[key]).length > 0; @@ -31,6 +33,8 @@ function isKeepableModelPart( includeReasoning: boolean, ): boolean { if (!isRecord(part) || typeof part.type !== "string") return false; + const descriptor = getOwnPropertyDescriptor(part, "providerExecuted"); + const providerExecuted = descriptor && hasOwn(descriptor, "value") ? descriptor.value : undefined; switch (part.type) { case "text": @@ -48,13 +52,13 @@ function isKeepableModelPart( hasNonEmptyStringField(part, "toolCallId") && hasNonEmptyStringField(part, "toolName") && isRecord(part.input) && - (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); + (providerExecuted === undefined || typeof providerExecuted === "boolean"); case "tool-result": return (role === "assistant" || role === "tool") && hasNonEmptyStringField(part, "toolCallId") && hasNonEmptyStringField(part, "toolName") && hasValidToolResultOutput(part.output) && - (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); + (providerExecuted === undefined || typeof providerExecuted === "boolean"); case "image": case "file": { if ( diff --git a/tests/integration/agent/provider-optional-fields-intrinsics.test.ts b/tests/integration/agent/provider-optional-fields-intrinsics.test.ts new file mode 100644 index 0000000000..1d731b47a7 --- /dev/null +++ b/tests/integration/agent/provider-optional-fields-intrinsics.test.ts @@ -0,0 +1,110 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { convertToTextGenerationRuntimeMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; +import { cleanContent } from "#veryfront/chat/provider-message-content.ts"; +import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; +import { getTurnProviderRequestValidator } from "#veryfront/agent/middleware/turn-validation.ts"; +import type { AgentContext, Message } from "#veryfront/agent/types.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private provider optional fields ${hooks ? "hooks" : "baseline"}`, () => { + it("replays local tool arguments without consulting an inherited provider flag", () => { + const marker = "synthetic-private-provider-flag"; + const messages: Message[] = [{ + id: "assistant", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: "call", + toolName: "inspect", + args: { text: marker }, + }], + }]; + const original = Object.getOwnPropertyDescriptor(Object.prototype, "providerExecuted"); + const descriptor = Object.getOwnPropertyDescriptor; + const defineProperty = Object.defineProperty; + let observations = 0; + let converted; + let cleaned; + try { + if (hooks) { + defineProperty(Object.prototype, "providerExecuted", { + configurable: true, + get() { + if ( + descriptor(this, "args")?.value?.text === marker || + descriptor(this, "input")?.value?.text === marker + ) observations++; + return undefined; + }, + }); + } + converted = convertToTextGenerationRuntimeMessages(messages); + const first = converted[0]!; + if (first.role === "assistant" && typeof first.content !== "string") { + cleaned = cleanContent(first.content, first.role); + } + } finally { + if (hooks) { + if (original) defineProperty(Object.prototype, "providerExecuted", original); + else Reflect.deleteProperty(Object.prototype, "providerExecuted"); + } + } + assertEquals(converted, [{ + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: "call", + toolName: "inspect", + input: { text: marker }, + }], + }]); + assertEquals(observations, 0); + assertEquals(cleaned, converted?.[0]?.content); + }); + + it("validates multiple trusted instruction layers without consulting Array.at", async () => { + const marker = "synthetic-private-trusted-layer"; + const input: Message[] = [{ + id: "caller", + role: "system", + parts: [{ type: "text", text: "Synthetic caller instructions" }], + }]; + const context: AgentContext = { + agentId: "synthetic", + input, + model: "hosted/synthetic", + data: {}, + platform: {}, + }; + await securityMiddleware({ input: {} })( + context, + () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), + ); + const at = Array.prototype.at; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let validated = false; + try { + if (hooks) { + Array.prototype.at = function (...args) { + if (apply(includes, stringify(this), [marker])) observations++; + return apply(at, this, args); + }; + } + await getTurnProviderRequestValidator(context)!([ + { role: "system", content: `${marker} first` }, + { role: "system", content: `${marker} second` }, + ], input); + validated = true; + } finally { + if (hooks) Array.prototype.at = at; + } + assertEquals(validated, true); + assertEquals(observations, 0); + }); + }); +} From c64d7e1ad84b7802a6f5221fafcffed3a86c9dc3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 20:13:35 +0200 Subject: [PATCH 155/194] fix(agent): protect stream assembly and replay collections --- .../runtime/provider-replay-emission.test.ts | 20 + src/agent/runtime/provider-replay.test.ts | 6 + src/agent/runtime/provider-replay.ts | 391 ++++++++++++------ .../streamed-assistant-message.test.ts | 34 ++ .../runtime/streamed-assistant-message.ts | 13 +- src/agent/streaming/lifecycle/live-adapter.ts | 20 +- src/agent/streaming/lifecycle/reducer.test.ts | 65 +++ src/agent/streaming/lifecycle/reducer.ts | 86 ++-- src/chat/json-value.test.ts | 15 + src/chat/json-value.ts | 108 +++-- src/security/private-array.test.ts | 29 ++ src/security/private-array.ts | 25 ++ .../lifecycle-collection-intrinsics.test.ts | 180 ++++++++ ...y-checkpoint-collection-intrinsics.test.ts | 90 ++++ ...amed-assistant-assembly-intrinsics.test.ts | 100 +++++ 15 files changed, 963 insertions(+), 219 deletions(-) create mode 100644 tests/integration/agent/lifecycle-collection-intrinsics.test.ts create mode 100644 tests/integration/agent/replay-checkpoint-collection-intrinsics.test.ts create mode 100644 tests/integration/agent/streamed-assistant-assembly-intrinsics.test.ts diff --git a/src/agent/runtime/provider-replay-emission.test.ts b/src/agent/runtime/provider-replay-emission.test.ts index 16446c7a69..3234d6c9e6 100644 --- a/src/agent/runtime/provider-replay-emission.test.ts +++ b/src/agent/runtime/provider-replay-emission.test.ts @@ -39,6 +39,26 @@ function lookupTool(onExecute: () => void = () => {}) { } describe("provider replay checkpoint emission", () => { + it("accumulates private provider blocks without consulting the buffer append method", () => { + const state = createProviderReplayCheckpointEmissionState({ messageId: MESSAGE_ID }); + let observations = 0; + Object.defineProperty(state.rawAssistantMessages, "push", { + value: function (this: unknown[], ...items: unknown[]) { + observations++; + return Reflect.apply(Array.prototype.push, this, items); + }, + }); + const checkpoint = captureProviderReplayCheckpoint( + state, + metadata([{ + type: "thinking", + thinking: "synthetic private reasoning", + signature: SIGNATURE, + }]), + ); + assertEquals(checkpoint?.providerBlocks[0]?.block.thinking, "synthetic private reasoning"); + assertEquals(observations, 0); + }); it("restores an existing checkpoint without consulting its array find method", async () => { const prior: ProviderReplayCheckpoint = { version: 1, diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 0aa93bafec..27e3d98b32 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -388,6 +388,12 @@ describe("agent/runtime/provider-replay", () => { }); describe("parseServerResolvedProviderReplayCheckpoints", () => { + it("rejects sparse deliveries before applying any checkpoint", () => { + const delivery = new Array(2); + delivery[1] = createValidCheckpoint(); + assertProviderReplayError(() => parseServerResolvedProviderReplayCheckpoints(delivery)); + assertProviderReplayError(() => applyProviderReplayCheckpointsToMessages([], delivery)); + }); it("should parse a valid checkpoint array", () => { const checkpoint = createValidCheckpoint(); assertEquals( diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 8e205d5b4a..8b1363c582 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1,5 +1,22 @@ -import { filterPrivateArray } from "#veryfront/security/private-array.ts"; -import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; +import { + appendPrivateArray, + concatPrivateArrays, + everyPrivateArray, + filterPrivateArray, + flatMapPrivateArray, + mapPrivateArray, + pushPrivateArray, + slicePrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { + encodePrivateText, + PrivateTextEncoder, + privateTextTrim, +} from "#veryfront/security/private-text.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; import { @@ -25,13 +42,20 @@ import { } from "./anthropic-provider-replay-block.ts"; import { readOwnDataProperty } from "./data-property-descriptor.ts"; +const isArray = Array.isArray; +const hasOwn = Object.hasOwn; +const objectKeys = Object.keys; +const objectEntries = Object.entries; +const numberIsSafeInteger = Number.isSafeInteger; +const numberIsFinite = Number.isFinite; + const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; const MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH = 256; const UTF8_ENCODER = new PrivateTextEncoder(); -const CHECKPOINT_KEYS = new Set([ +const CHECKPOINT_KEYS = createPrivateSet([ "version", "messageId", "provider", @@ -42,8 +66,8 @@ const CHECKPOINT_KEYS = new Set([ "elapsedMs", "emittedAt", ]); -const BLOCK_KEYS = new Set(["type", "provider", "block"]); -const WEB_SEARCH_ERROR_CODES = new Set([ +const BLOCK_KEYS = createPrivateSet(["type", "provider", "block"]); +const WEB_SEARCH_ERROR_CODES = createPrivateSet([ "invalid_tool_input", "unavailable", "max_uses_exceeded", @@ -51,7 +75,7 @@ const WEB_SEARCH_ERROR_CODES = new Set([ "query_too_long", "request_too_large", ]); -const WEB_FETCH_ERROR_CODES = new Set([ +const WEB_FETCH_ERROR_CODES = createPrivateSet([ "invalid_tool_input", "url_too_long", "url_not_allowed", @@ -62,17 +86,17 @@ const WEB_FETCH_ERROR_CODES = new Set([ "max_uses_exceeded", "unavailable", ]); -const CODE_EXECUTION_ERROR_CODES = new Set([ +const CODE_EXECUTION_ERROR_CODES = createPrivateSet([ "invalid_tool_input", "unavailable", "too_many_requests", "execution_time_exceeded", ]); -const BASH_CODE_EXECUTION_ERROR_CODES = new Set([ +const BASH_CODE_EXECUTION_ERROR_CODES = createPrivateSet([ ...CODE_EXECUTION_ERROR_CODES, "output_file_too_large", ]); -const TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES = new Set([ +const TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES = createPrivateSet([ ...CODE_EXECUTION_ERROR_CODES, "file_not_found", ]); @@ -138,7 +162,7 @@ function invalidCheckpoint(detail: string, context?: Record): n } function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); + return value !== null && typeof value === "object" && !isArray(value); } function isProviderReplayProvider(value: unknown): value is ProviderReplayProvider { @@ -178,27 +202,33 @@ function snapshotAnthropicRawAssistantMessagesForEmission( } catch { invalidCheckpoint("provider replay emission metadata exceeds raw metadata bounds"); } - if (!Array.isArray(snapshot) || snapshot.length === 0) { + if (!isArray(snapshot) || snapshot.length === 0) { invalidCheckpoint("provider replay emission metadata must contain raw assistant messages"); } const groups: Record[][] = []; - for (const [messageIndex, rawAssistantMessage] of snapshot.entries()) { - if (!Array.isArray(rawAssistantMessage) || rawAssistantMessage.length === 0) { + for (let messageIndex = 0; messageIndex < snapshot.length; messageIndex++) { + const rawAssistantMessage = hasOwn(snapshot, messageIndex) + ? snapshot[messageIndex]! + : undefined; + if (!isArray(rawAssistantMessage) || rawAssistantMessage.length === 0) { invalidCheckpoint("provider replay emission raw assistant message must contain blocks", { messageIndex, }); } const blocks: Record[] = []; - for (const [blockIndex, block] of rawAssistantMessage.entries()) { + for (let blockIndex = 0; blockIndex < rawAssistantMessage.length; blockIndex++) { + const block = hasOwn(rawAssistantMessage, blockIndex) + ? rawAssistantMessage[blockIndex]! + : undefined; if (!isRecord(block)) { invalidCheckpoint("provider replay emission block must be an object", { messageIndex, blockIndex, }); } - blocks.push(block); + pushPrivateArray(blocks, block); } - groups.push(blocks); + pushPrivateArray(groups, blocks); } return groups; } @@ -230,9 +260,13 @@ export function createProviderReplayCheckpointEmissionState(input: { : []; return { messageId: input.messageId, - rawAssistantMessages: rawAssistantMessages.map((blocks) => [...blocks]), - replayRequired: rawAssistantMessages.some((blocks) => - blocks.some(isReplayRequiredAnthropicBlock) + rawAssistantMessages: mapPrivateArray( + rawAssistantMessages, + (blocks) => mapPrivateArray(blocks, (block) => block), + ), + replayRequired: somePrivateArray( + rawAssistantMessages, + (blocks) => somePrivateArray(blocks, isReplayRequiredAnthropicBlock), ), }; } @@ -244,24 +278,28 @@ export function captureProviderReplayCheckpoint( ): ProviderReplayCheckpoint | undefined { const rawAssistantMessages = snapshotAnthropicRawAssistantMessagesForEmission(providerMetadata); if (rawAssistantMessages === undefined) return undefined; - state.rawAssistantMessages.push(...rawAssistantMessages); - state.replayRequired ||= rawAssistantMessages.some((blocks) => - blocks.some(isReplayRequiredAnthropicBlock) + appendPrivateArray(state.rawAssistantMessages, rawAssistantMessages); + state.replayRequired ||= somePrivateArray( + rawAssistantMessages, + (blocks) => somePrivateArray(blocks, isReplayRequiredAnthropicBlock), ); if (!state.replayRequired) return undefined; - const blocks = state.rawAssistantMessages.flat(); + const blocks = flatMapPrivateArray(state.rawAssistantMessages, (group) => group); const checkpoint = parseProviderReplayCheckpoint({ version: 1, messageId: state.messageId, provider: "anthropic", - providerBlocks: blocks.map((block) => ({ + providerBlocks: mapPrivateArray(blocks, (block) => ({ type: "provider-block", provider: "anthropic", block, })), - providerBlockPositions: blocks.map((_, index) => index), - providerMessageBlockCounts: state.rawAssistantMessages.map((group) => group.length), + providerBlockPositions: mapPrivateArray(blocks, (_, index) => index), + providerMessageBlockCounts: mapPrivateArray( + state.rawAssistantMessages, + (group) => group.length, + ), totalPartCount: blocks.length, }); const eventForSizeCheck = { @@ -309,8 +347,16 @@ export function parseProviderReplayCheckpointEvent( invalidCheckpoint("provider replay checkpoint event type is invalid"); } const checkpointValue: Record = {}; - for (const [key, entry] of Object.entries(snapshot)) { - if (key !== "type") checkpointValue[key] = entry; + const entries = objectEntries(snapshot); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + if (entry[0] !== "type") { + defineOwnDataProperty(checkpointValue, entry[0], entry[1], { + enumerable: true, + configurable: true, + writable: true, + }); + } } return { type: AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, @@ -323,12 +369,12 @@ function isNonEmptyString(value: unknown): value is string { } function isSafeInteger(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value); + return typeof value === "number" && numberIsSafeInteger(value); } function isNullableNonNegativeSafeInteger(value: unknown): value is number | null { return value === null || - typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + typeof value === "number" && numberIsSafeInteger(value) && value >= 0; } function assertRawProviderMetadataBounds( @@ -401,10 +447,10 @@ function hasSupportedAnthropicProviderToolResultContent( block: Record, ): boolean { if (block.type === "mcp_tool_result") { - return typeof block.content === "string" || Array.isArray(block.content); + return typeof block.content === "string" || isArray(block.content); } if (block.type === "web_search_tool_result") { - return Array.isArray(block.content) || isRecord(block.content); + return isArray(block.content) || isRecord(block.content); } return isRecord(block.content); } @@ -483,20 +529,23 @@ function hasValidAnthropicErrorContent( function hasValidAnthropicMcpContent(value: unknown): boolean { if (typeof value === "string") return true; - if (!Array.isArray(value)) return false; - return value.every((item) => { + if (!isArray(value)) return false; + return everyPrivateArray(value, (item) => { if (!isRecord(item) || item.type !== "text" || typeof item.text !== "string") return false; const citations = item.citations; return citations === undefined || citations === null || - Array.isArray(citations) && - citations.every((citation) => isRecord(citation) && isNonEmptyString(citation.type)); + isArray(citations) && + everyPrivateArray( + citations, + (citation) => isRecord(citation) && isNonEmptyString(citation.type), + ); }); } function normalizeAnthropicMcpContent(value: unknown): unknown { if (typeof value === "string") return value; - if (!Array.isArray(value)) return value; - return value.map((item) => { + if (!isArray(value)) return value; + return mapPrivateArray(value, (item) => { if (!isRecord(item)) return item; return { type: "text", @@ -510,9 +559,10 @@ function hasValidAnthropicFileOutputs( value: unknown, expectedType: "code_execution_output" | "bash_code_execution_output", ): boolean { - return Array.isArray(value) && - value.every((item) => - isRecord(item) && item.type === expectedType && isNonEmptyString(item.file_id) + return isArray(value) && + everyPrivateArray( + value, + (item) => isRecord(item) && item.type === expectedType && isNonEmptyString(item.file_id), ); } @@ -589,8 +639,8 @@ function hasValidAnthropicTextEditorCodeExecutionContent( if (content.type === "text_editor_code_execution_str_replace_result") { return "lines" in content && (content.lines === null || - Array.isArray(content.lines) && - content.lines.every((line) => typeof line === "string")) && + isArray(content.lines) && + everyPrivateArray(content.lines, (line) => typeof line === "string")) && "old_start" in content && isNullableNonNegativeSafeInteger(content.old_start) && "old_lines" in content && @@ -611,16 +661,15 @@ function hasValidAnthropicWebSearchContent(content: unknown): boolean { WEB_SEARCH_ERROR_CODES, ); } - return Array.isArray(content) && - content.every((item) => + return isArray(content) && + everyPrivateArray(content, (item) => isRecord(item) && item.type === "web_search_result" && isNonEmptyString(item.url) && typeof item.title === "string" && typeof item.encrypted_content === "string" && "page_age" in item && - (item.page_age === null || typeof item.page_age === "string") - ); + (item.page_age === null || typeof item.page_age === "string")); } function hasValidAnthropicWebFetchSource(value: unknown): boolean { @@ -692,8 +741,8 @@ function normalizeAnthropicFileOutputs( value: unknown, expectedType: "code_execution_output" | "bash_code_execution_output", ): Array> { - return Array.isArray(value) - ? value.map((item) => { + return isArray(value) + ? mapPrivateArray(value, (item) => { const record = isRecord(item) ? item : {}; return { type: expectedType, fileId: record.file_id }; }) @@ -707,8 +756,8 @@ function normalizeAnthropicWebSearchContent(content: unknown): unknown { WEB_SEARCH_ERROR_CODES, ); if (error !== undefined) return error; - if (!Array.isArray(content)) return content; - return content.map((item) => { + if (!isArray(content)) return content; + return mapPrivateArray(content, (item) => { if (!isRecord(item) || item.type !== "web_search_result") return item; return { type: "web_search_result", @@ -962,8 +1011,8 @@ type AnthropicProviderToolCorrelationState = { function createAnthropicProviderToolCorrelationState(): AnthropicProviderToolCorrelationState { return { - pendingProviderTools: new Map(), - toolUseIds: new Set(), + pendingProviderTools: createPrivateMap(), + toolUseIds: createPrivateSet(), }; } @@ -1035,13 +1084,19 @@ function assertAnthropicProviderToolResultsMatchTranscript( messages: readonly Message[], checkpoints: readonly ProviderReplayCheckpoint[], ): void { - const checkpointsByMessageId = new Map(); - for (const checkpoint of checkpoints) { + const checkpointsByMessageId = createPrivateMap(); + for (let checkpointIndex = 0; checkpointIndex < checkpoints.length; checkpointIndex++) { + if (!hasOwn(checkpoints, checkpointIndex)) { + invalidCheckpoint("checkpoint delivery must not contain missing entries"); + } + const checkpoint = checkpoints[checkpointIndex]!; checkpointsByMessageId.set(checkpoint.messageId, checkpoint); } const state = createAnthropicProviderToolCorrelationState(); - const visitedCheckpointMessageIds = new Set(); - for (const message of messages) { + const visitedCheckpointMessageIds = createPrivateSet(); + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!hasOwn(messages, messageIndex)) continue; + const message = messages[messageIndex]!; if (message.role === "user" || message.role === "system") { resetAnthropicProviderToolCorrelationState(state); continue; @@ -1050,7 +1105,13 @@ function assertAnthropicProviderToolResultsMatchTranscript( const checkpoint = checkpointsByMessageId.get(message.id); if (!checkpoint) continue; visitedCheckpointMessageIds.add(message.id); - for (const replayBlock of checkpoint.providerBlocks) { + for ( + let replayBlockIndex = 0; + replayBlockIndex < checkpoint.providerBlocks.length; + replayBlockIndex++ + ) { + if (!hasOwn(checkpoint.providerBlocks, replayBlockIndex)) continue; + const replayBlock = checkpoint.providerBlocks[replayBlockIndex]!; validateAnthropicProviderToolCorrelationBlock(replayBlock.block, state); } } @@ -1104,8 +1165,8 @@ function isNormalizedAnthropicProviderErrorResult(value: unknown): boolean { } function getProviderExecutedToolCallIds(target: Message): Set { - return new Set( - target.parts.flatMap((part) => { + return createPrivateSet( + flatMapPrivateArray(target.parts, (part) => { const value: unknown = part; return isRecord(value) && value.type === "tool-call" && value.providerExecuted === true && @@ -1160,7 +1221,7 @@ function toTranscriptVisibleProviderPart( } function unwrapPreparedProviderResult(value: unknown): unknown { - if (!isRecord(value) || !Object.hasOwn(value, "value")) return value; + if (!isRecord(value) || !hasOwn(value, "value")) return value; if (value.type === "json") return value.value; if (value.type !== "error-text" || typeof value.value !== "string") return value; const parsed = safeJsonParse(value.value); @@ -1176,7 +1237,9 @@ function normalizeTranscriptVisibleProjection( const normalized: Record[] = []; let text = ""; - for (const part of parts) { + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + if (!hasOwn(parts, partIndex)) continue; + const part = parts[partIndex]!; if (part.type === "text") { if (typeof part.text !== "string") { invalidCheckpoint("checkpoint transcript text projection is malformed"); @@ -1184,19 +1247,17 @@ function normalizeTranscriptVisibleProjection( text += part.text; continue; } - normalized.push(part); + pushPrivateArray(normalized, part); } - if (text.trim().length > 0) { - normalized.unshift({ type: "text", text }); - } - - return normalized; + return privateTextTrim(text).length > 0 + ? concatPrivateArrays([{ type: "text", text }], normalized) + : normalized; } function projectCheckpointVisibleParts( checkpoint: ProviderReplayCheckpoint, ): Record[] { - return checkpoint.providerBlocks.flatMap((block) => { + return flatMapPrivateArray(checkpoint.providerBlocks, (block) => { const part = toTranscriptVisibleAnthropicReplayPart(block.block); return part ? [part] : []; }); @@ -1205,8 +1266,10 @@ function projectCheckpointVisibleParts( function getProviderExecutedToolCallIdsFromMessages( messages: readonly Message[], ): Set { - const ids = new Set(); - for (const message of messages) { + const ids = createPrivateSet(); + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!hasOwn(messages, messageIndex)) continue; + const message = messages[messageIndex]!; for (const id of getProviderExecutedToolCallIds(message)) { ids.add(id); } @@ -1232,7 +1295,7 @@ function getMessageSegmentForTarget( if (next.role === "user" || next.role === "system") break; end += 1; } - return messages.slice(start, end); + return slicePrivateArray(messages, start, end); } function getProviderExecutedToolCallIdsForTargetSegment( @@ -1245,7 +1308,7 @@ function getProviderExecutedToolCallIdsForTargetSegment( ); for ( const id of collectAnthropicProviderToolCallIds([ - checkpoint.providerBlocks.map((block) => block.block), + mapPrivateArray(checkpoint.providerBlocks, (block) => block.block), ]) ) { ids.add(id); @@ -1257,14 +1320,13 @@ function projectProviderToolResults( messages: readonly Message[], providerExecutedToolCallIds: ReadonlySet, ): Record[] { - return messages.flatMap((message) => - message.parts.flatMap((part) => { + return flatMapPrivateArray(messages, (message) => + flatMapPrivateArray(message.parts, (part) => { const value: unknown = part; if (!isRecord(value) || value.type !== "tool-result") return []; const projected = toTranscriptVisibleProviderPart(value, providerExecutedToolCallIds); return projected?.providerExecuted === true ? [projected] : []; - }) - ); + })); } function assertCheckpointMatchesProjection( @@ -1297,19 +1359,21 @@ function assertCheckpointMatchesAssistantTurn( toolSiblings: readonly Message[] = [], providerExecutedToolCallIds = getProviderExecutedToolCallIds(target), ): void { - const providerProjection = convertAgentRuntimeMessagesToProviderMessages([target]) - .filter((message) => message.role === "assistant"); + const providerProjection = filterPrivateArray( + convertAgentRuntimeMessagesToProviderMessages([target]), + (message) => message.role === "assistant", + ); if (providerProjection.length > 1) { if (checkpoint.providerMessageBlockCounts?.length !== providerProjection.length) { invalidCheckpoint("checkpoint anchor projects to more than one assistant message", { assistantSegmentCount: providerProjection.length, }); } - const splitTargetProjection = providerProjection.flatMap((message) => { - if (!Array.isArray(message.content)) { + const splitTargetProjection = flatMapPrivateArray(providerProjection, (message) => { + if (!isArray(message.content)) { invalidCheckpoint("checkpoint anchor does not carry structured assistant content"); } - return message.content.flatMap((part) => { + return flatMapPrivateArray(message.content, (part) => { const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); return projected ? [projected] : []; }); @@ -1317,22 +1381,28 @@ function assertCheckpointMatchesAssistantTurn( assertCheckpointMatchesProjection( checkpoint, splitTargetProjection, - projectProviderToolResults([target, ...toolSiblings], providerExecutedToolCallIds), + projectProviderToolResults( + concatPrivateArrays([target], toolSiblings), + providerExecutedToolCallIds, + ), ); return; } const targetContent = providerProjection[0]?.content ?? []; - if (!Array.isArray(targetContent)) { + if (!isArray(targetContent)) { invalidCheckpoint("checkpoint anchor does not carry structured assistant content"); } - const targetProjection = targetContent.flatMap((part) => { + const targetProjection = flatMapPrivateArray(targetContent, (part) => { const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); return projected ? [projected] : []; }); assertCheckpointMatchesProjection( checkpoint, targetProjection, - projectProviderToolResults([target, ...toolSiblings], providerExecutedToolCallIds), + projectProviderToolResults( + concatPrivateArrays([target], toolSiblings), + providerExecutedToolCallIds, + ), ); } @@ -1342,12 +1412,12 @@ function createCheckpointForRawBlocks( ): ProviderReplayCheckpoint { return { ...source, - providerBlocks: rawBlocks.map((block) => ({ + providerBlocks: mapPrivateArray(rawBlocks, (block) => ({ type: "provider-block", provider: source.provider, block, })), - providerBlockPositions: rawBlocks.map((_, index) => index), + providerBlockPositions: mapPrivateArray(rawBlocks, (_, index) => index), totalPartCount: rawBlocks.length, }; } @@ -1356,13 +1426,23 @@ function getRawAssistantMessagesForCheckpoint( checkpoint: ProviderReplayCheckpoint, ): Record[][] { if (checkpoint.providerMessageBlockCounts === undefined) { - return [checkpoint.providerBlocks.map((block) => block.block)]; + return [mapPrivateArray(checkpoint.providerBlocks, (block) => block.block)]; } const rawAssistantMessages: Record[][] = []; let offset = 0; - for (const count of checkpoint.providerMessageBlockCounts) { - rawAssistantMessages.push( - checkpoint.providerBlocks.slice(offset, offset + count).map((block) => block.block), + for ( + let countIndex = 0; + countIndex < checkpoint.providerMessageBlockCounts.length; + countIndex++ + ) { + if (!hasOwn(checkpoint.providerMessageBlockCounts, countIndex)) continue; + const count = checkpoint.providerMessageBlockCounts[countIndex]!; + pushPrivateArray( + rawAssistantMessages, + mapPrivateArray( + slicePrivateArray(checkpoint.providerBlocks, offset, offset + count), + (block) => block.block, + ), ); offset += count; } @@ -1385,21 +1465,27 @@ function splitAnthropicAssistantReplayBlocks( } const segments: Record[][] = []; let current: Record[] = []; - for (const replayBlock of checkpoint.providerBlocks) { + for ( + let replayBlockIndex = 0; + replayBlockIndex < checkpoint.providerBlocks.length; + replayBlockIndex++ + ) { + if (!hasOwn(checkpoint.providerBlocks, replayBlockIndex)) continue; + const replayBlock = checkpoint.providerBlocks[replayBlockIndex]!; if (isAnthropicProviderToolResultBlock(replayBlock.block)) { - if (current.some((block) => !isAnthropicProviderToolResultBlock(block))) { - segments.push(current); + if (somePrivateArray(current, (block) => !isAnthropicProviderToolResultBlock(block))) { + pushPrivateArray(segments, current); current = []; } - current.push(replayBlock.block); + pushPrivateArray(current, replayBlock.block); continue; } - current.push(replayBlock.block); + pushPrivateArray(current, replayBlock.block); } if (current.length > 0) { - segments.push(current); + pushPrivateArray(segments, current); } - return segments.map((segment) => [segment]); + return mapPrivateArray(segments, (segment) => [segment]); } function assertCheckpointMatchesSplitAssistantTurns( @@ -1412,16 +1498,18 @@ function assertCheckpointMatchesSplitAssistantTurns( ); for ( const id of collectAnthropicProviderToolCallIds([ - checkpoint.providerBlocks.map((block) => block.block), + mapPrivateArray(checkpoint.providerBlocks, (block) => block.block), ]) ) { providerExecutedToolCallIds.add(id); } - const targetProjection = assistantMatches.flatMap((message) => - message.parts.flatMap((part) => { - const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); - return projected ? [projected] : []; - }) + const targetProjection = flatMapPrivateArray( + assistantMatches, + (message) => + flatMapPrivateArray(message.parts, (part) => { + const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); + return projected ? [projected] : []; + }), ); assertCheckpointMatchesProjection( checkpoint, @@ -1433,15 +1521,17 @@ function assertCheckpointMatchesSplitAssistantTurns( if (rawSegments.length !== assistantMatches.length) { invalidCheckpoint("checkpoint split assistant segment count does not match its anchor"); } - for (const [index, rawSegment] of rawSegments.entries()) { + for (let index = 0; index < rawSegments.length; index++) { + if (!hasOwn(rawSegments, index)) continue; + const rawSegment = rawSegments[index]!; const rawSegmentProjection = projectCheckpointVisibleParts( - createCheckpointForRawBlocks(checkpoint, rawSegment.flat()), + createCheckpointForRawBlocks(checkpoint, flatMapPrivateArray(rawSegment, (group) => group)), ); const rawSegmentAssistantProjection = normalizeTranscriptVisibleProjection( filterPrivateArray(rawSegmentProjection, (part) => part.type !== "tool-result"), ); const assistantProjection = normalizeTranscriptVisibleProjection( - assistantMatches[index]!.parts.flatMap((part) => { + flatMapPrivateArray(assistantMatches[index]!.parts, (part) => { const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); return projected ? [projected] : []; }), @@ -1466,7 +1556,9 @@ function parseProviderReplayBlock( } // Unknown key NAMES are attacker-controlled text and may smuggle signed // material, so rejections report the index only, never the key. - for (const key of Object.keys(value)) { + const keys = objectKeys(value); + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + const key = keys[keyIndex]!; if (!BLOCK_KEYS.has(key)) { invalidCheckpoint("provider block carries an unknown key", { index }); } @@ -1504,7 +1596,9 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe invalidCheckpoint("checkpoint must be an object"); } // As with block keys: never echo an unknown key name. - for (const key of Object.keys(value)) { + const keys = objectKeys(value); + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + const key = keys[keyIndex]!; if (!CHECKPOINT_KEYS.has(key)) { invalidCheckpoint("checkpoint carries an unknown key"); } @@ -1523,7 +1617,7 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe invalidCheckpoint("checkpoint provider is not a replay-capable provider"); } if ( - !Array.isArray(value.providerBlocks) || + !isArray(value.providerBlocks) || value.providerBlocks.length === 0 || value.providerBlocks.length > MAX_PROVIDER_REPLAY_BLOCKS ) { @@ -1532,12 +1626,13 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe ); } const provider = value.provider; - const providerBlocks = value.providerBlocks.map((block, index) => - parseProviderReplayBlock(block, provider, index) + const providerBlocks = mapPrivateArray( + value.providerBlocks, + (block, index) => parseProviderReplayBlock(block, provider, index), ); if ( typeof value.totalPartCount !== "number" || - !Number.isSafeInteger(value.totalPartCount) || + !numberIsSafeInteger(value.totalPartCount) || value.totalPartCount < 1 || value.totalPartCount > MAX_PROVIDER_REPLAY_TOTAL_PARTS ) { @@ -1549,16 +1644,19 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe invalidCheckpoint("checkpoint totalPartCount cannot be lower than the block count"); } if ( - !Array.isArray(value.providerBlockPositions) || + !isArray(value.providerBlockPositions) || value.providerBlockPositions.length !== providerBlocks.length ) { invalidCheckpoint("checkpoint providerBlockPositions must align one-to-one with blocks"); } const positions: number[] = []; - for (const [index, position] of value.providerBlockPositions.entries()) { + for (let index = 0; index < value.providerBlockPositions.length; index++) { + const position = hasOwn(value.providerBlockPositions, index) + ? value.providerBlockPositions[index]! + : undefined; if ( typeof position !== "number" || - !Number.isSafeInteger(position) || + !numberIsSafeInteger(position) || position < 0 || position >= value.totalPartCount ) { @@ -1566,26 +1664,29 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe index, }); } - const previous = positions.at(-1); + const previous = positions.length > 0 ? positions[positions.length - 1] : undefined; if (previous !== undefined && position <= previous) { invalidCheckpoint("checkpoint block positions must be strictly increasing", { index }); } - positions.push(position); + pushPrivateArray(positions, position); } let providerMessageBlockCounts: number[] | undefined; if (value.providerMessageBlockCounts !== undefined) { if ( - !Array.isArray(value.providerMessageBlockCounts) || + !isArray(value.providerMessageBlockCounts) || value.providerMessageBlockCounts.length === 0 ) { invalidCheckpoint("checkpoint providerMessageBlockCounts must be a non-empty array"); } providerMessageBlockCounts = []; let groupedBlockCount = 0; - for (const [index, count] of value.providerMessageBlockCounts.entries()) { + for (let index = 0; index < value.providerMessageBlockCounts.length; index++) { + const count = hasOwn(value.providerMessageBlockCounts, index) + ? value.providerMessageBlockCounts[index]! + : undefined; if ( typeof count !== "number" || - !Number.isSafeInteger(count) || + !numberIsSafeInteger(count) || count <= 0 ) { invalidCheckpoint("checkpoint providerMessageBlockCounts entries must be positive", { @@ -1593,7 +1694,7 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe }); } groupedBlockCount += count; - providerMessageBlockCounts.push(count); + pushPrivateArray(providerMessageBlockCounts, count); } if (groupedBlockCount !== providerBlocks.length) { invalidCheckpoint("checkpoint providerMessageBlockCounts must cover every block"); @@ -1601,14 +1702,14 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe } if ( value.elapsedMs !== undefined && - (typeof value.elapsedMs !== "number" || !Number.isFinite(value.elapsedMs) || + (typeof value.elapsedMs !== "number" || !numberIsFinite(value.elapsedMs) || value.elapsedMs < 0) ) { invalidCheckpoint("checkpoint elapsedMs must be a finite non-negative number"); } if ( value.emittedAt !== undefined && - (typeof value.emittedAt !== "number" || !Number.isSafeInteger(value.emittedAt) || + (typeof value.emittedAt !== "number" || !numberIsSafeInteger(value.emittedAt) || value.emittedAt < 0) ) { invalidCheckpoint("checkpoint emittedAt must be a non-negative integer"); @@ -1642,7 +1743,7 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe export function parseServerResolvedProviderReplayCheckpoints( value: unknown, ): ProviderReplayCheckpoint[] { - if (!Array.isArray(value)) { + if (!isArray(value)) { invalidCheckpoint("server-resolved provider replay checkpoints must be an array"); } if (value.length > MAX_PROVIDER_REPLAY_CHECKPOINTS) { @@ -1650,11 +1751,15 @@ export function parseServerResolvedProviderReplayCheckpoints( `server-resolved provider replay checkpoints must contain at most ${MAX_PROVIDER_REPLAY_CHECKPOINTS} entries`, ); } - const checkpoints = value.map((entry) => parseProviderReplayCheckpoint(entry)); + const checkpoints = mapPrivateArray(value, (entry) => parseProviderReplayCheckpoint(entry)); // The server resolves at most one checkpoint per assistant turn. Duplicates // would make replay state depend on array order, so they fail closed. - const messageIds = new Set(); - for (const checkpoint of checkpoints) { + const messageIds = createPrivateSet(); + for (let checkpointIndex = 0; checkpointIndex < checkpoints.length; checkpointIndex++) { + if (!hasOwn(checkpoints, checkpointIndex)) { + invalidCheckpoint("checkpoint delivery must not contain missing entries"); + } + const checkpoint = checkpoints[checkpointIndex]!; if (messageIds.has(checkpoint.messageId)) { invalidCheckpoint("delivery carries more than one checkpoint for one message anchor"); } @@ -1683,7 +1788,7 @@ export function assertReconstructibleProviderReplayCheckpoint( } if ( checkpoint.totalPartCount !== checkpoint.providerBlocks.length || - checkpoint.providerBlockPositions.some((position, index) => position !== index) + somePrivateArray(checkpoint.providerBlockPositions, (position, index) => position !== index) ) { invalidCheckpoint( "sparse provider replay checkpoints are not reconstructible by this runtime version", @@ -1718,7 +1823,11 @@ export function applyProviderReplayCheckpointsToMessages( // Runtime support is a property of the delivery, not of which turns are // still in context: an unsupported checkpoint fails the run even when its // turn is absent, so deployment skew surfaces immediately. - for (const checkpoint of checkpoints) { + for (let checkpointIndex = 0; checkpointIndex < checkpoints.length; checkpointIndex++) { + if (!hasOwn(checkpoints, checkpointIndex)) { + invalidCheckpoint("checkpoint delivery must not contain missing entries"); + } + const checkpoint = checkpoints[checkpointIndex]!; assertReconstructibleProviderReplayCheckpoint(checkpoint); if (options.activeProvider === "unsupported") { invalidCheckpoint("active model provider cannot replay provider checkpoints"); @@ -1731,7 +1840,11 @@ export function applyProviderReplayCheckpointsToMessages( } } assertAnthropicProviderToolResultsMatchTranscript(messages, checkpoints); - for (const checkpoint of checkpoints) { + for (let checkpointIndex = 0; checkpointIndex < checkpoints.length; checkpointIndex++) { + if (!hasOwn(checkpoints, checkpointIndex)) { + invalidCheckpoint("checkpoint delivery must not contain missing entries"); + } + const checkpoint = checkpoints[checkpointIndex]!; const matches = filterPrivateArray(messages, (message) => message.id === checkpoint.messageId); if (matches.length === 0) continue; const assistantMatches = filterPrivateArray(matches, (message) => message.role === "assistant"); @@ -1755,7 +1868,7 @@ export function applyProviderReplayCheckpointsToMessages( toolSiblings, providerExecutedToolCallIds, ); - attachmentPlan.push({ + pushPrivateArray(attachmentPlan, { target, rawAssistantMessages: getRawAssistantMessagesForCheckpoint(checkpoint), }); @@ -1766,11 +1879,17 @@ export function applyProviderReplayCheckpointsToMessages( assistantMatches, checkpoint, ); - for (const [index, rawBlocks] of rawSegments.entries()) { - attachmentPlan.push({ target: assistantMatches[index]!, rawAssistantMessages: rawBlocks }); + for (let index = 0; index < rawSegments.length; index++) { + if (!hasOwn(rawSegments, index)) continue; + const rawBlocks = rawSegments[index]!; + pushPrivateArray(attachmentPlan, { + target: assistantMatches[index]!, + rawAssistantMessages: rawBlocks, + }); } } - for (const { target, rawAssistantMessages } of attachmentPlan) { + for (let index = 0; index < attachmentPlan.length; index++) { + const { target, rawAssistantMessages } = attachmentPlan[index]!; // In-process metadata attached during this run is the same replay state at // first hand; the durable checkpoint never overrides it. if (readAttachedProviderMetadata(target) !== undefined) continue; diff --git a/src/agent/runtime/streamed-assistant-message.test.ts b/src/agent/runtime/streamed-assistant-message.test.ts index cddbaf9da8..3f58dde542 100644 --- a/src/agent/runtime/streamed-assistant-message.test.ts +++ b/src/agent/runtime/streamed-assistant-message.test.ts @@ -8,6 +8,40 @@ import { } from "./streamed-assistant-message.ts"; describe("agent/streamed-assistant-message", () => { + it("assembles reasoning without invoking an own array iterator", () => { + const reasoningParts: ChatStreamState["reasoningParts"] = [ + { id: "reasoning_empty", text: "" }, + { id: "reasoning_text", text: "synthetic-private-reasoning", signature: "sig_1" }, + { id: "reasoning_redacted", text: "", redactedData: "redacted_1" }, + ]; + const iterator = reasoningParts[Symbol.iterator]; + let iteratorCalls = 0; + Object.defineProperty(reasoningParts, Symbol.iterator, { + value() { + iteratorCalls++; + return iterator.call(reasoningParts); + }, + }); + + const message = buildStreamedAssistantMessage({ + accumulatedText: "Final answer", + reasoningParts, + toolCalls: new Map(), + }, { id: "msg_private", timestamp: 123 }); + + assertEquals(message, { + id: "msg_private", + role: "assistant", + timestamp: 123, + parts: [ + { type: "reasoning", text: "synthetic-private-reasoning", signature: "sig_1" }, + { type: "reasoning", redactedData: "redacted_1" }, + { type: "text", text: "Final answer" }, + ], + }); + assertEquals(iteratorCalls, 0); + }); + it("builds an assistant message from completed stream state", () => { const state: ChatStreamState = { accumulatedText: "Final answer", diff --git a/src/agent/runtime/streamed-assistant-message.ts b/src/agent/runtime/streamed-assistant-message.ts index bd00967c22..4addc17cda 100644 --- a/src/agent/runtime/streamed-assistant-message.ts +++ b/src/agent/runtime/streamed-assistant-message.ts @@ -1,3 +1,4 @@ +import { pushPrivateArray } from "#veryfront/security/private-array.ts"; import type { Message, MessagePart } from "../types.ts"; import type { ChatStreamState, StreamingReasoningPart } from "./chat-stream-handler.ts"; import { @@ -5,6 +6,8 @@ import { shouldOmitRecoverablePlaceholderToolCall, } from "./tool-result-continuation.ts"; +const hasOwn = Object.hasOwn; + export interface StreamedAssistantMessageIdentity { id: string; timestamp: number; @@ -40,11 +43,13 @@ export function buildStreamedAssistantMessage( ): Message { const parts: MessagePart[] = []; - for (const reasoningPart of state.reasoningParts) { + for (let index = 0; index < state.reasoningParts.length; index++) { + if (!hasOwn(state.reasoningParts, index)) continue; + const reasoningPart = state.reasoningParts[index]!; if (!isPersistedReasoningPart(reasoningPart)) { continue; } - parts.push({ + pushPrivateArray(parts, { type: "reasoning", ...(reasoningPart.text.length > 0 ? { text: reasoningPart.text } : {}), ...(reasoningPart.signature ? { signature: reasoningPart.signature } : {}), @@ -53,7 +58,7 @@ export function buildStreamedAssistantMessage( } if (state.accumulatedText) { - parts.push({ type: "text", text: state.accumulatedText }); + pushPrivateArray(parts, { type: "text", text: state.accumulatedText }); } for (const toolCall of state.toolCalls.values()) { @@ -63,7 +68,7 @@ export function buildStreamedAssistantMessage( ) { continue; } - parts.push(materializeStreamedToolCall(toolCall).part); + pushPrivateArray(parts, materializeStreamedToolCall(toolCall).part); } return { diff --git a/src/agent/streaming/lifecycle/live-adapter.ts b/src/agent/streaming/lifecycle/live-adapter.ts index 48362684e7..8bdfa9a75e 100644 --- a/src/agent/streaming/lifecycle/live-adapter.ts +++ b/src/agent/streaming/lifecycle/live-adapter.ts @@ -1,4 +1,8 @@ -import { filterPrivateArray, mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { + filterPrivateArray, + mapPrivateArray, + pushPrivateArray, +} from "#veryfront/security/private-array.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; import type { ChatStreamState, @@ -23,7 +27,7 @@ interface LiveAdapterToolState { export function createStreamLifecycleLiveAdapter( input: { textPartId?: string }, ) { - const tools = new Map(); + const tools = createPrivateMap(); let activeTextPartId: string | undefined; let nextTextSegmentIndex = 0; const openTextPartId = (eventId: string | undefined): string => { @@ -94,7 +98,7 @@ export function createStreamLifecycleLiveAdapter( } case "tool_input_content": { const tool = tools.get(event.toolCallId); - if (tool) tool.deltas.push(event.delta); + if (tool) pushPrivateArray(tool.deltas, event.delta); return []; } case "tool_input_ready": { @@ -102,17 +106,17 @@ export function createStreamLifecycleLiveAdapter( const dynamic = event.dynamic ?? tool?.dynamic; const events: ChatStreamEvent[] = []; if (!tool?.announced && event.announced !== true) { - events.push({ + pushPrivateArray(events, { type: "tool-input-start", toolCallId: event.toolCallId, toolName: event.toolName, ...(dynamic ? { dynamic: true } : {}), }); - for (const delta of tool?.deltas ?? []) { - events.push({ + for (let index = 0; tool && index < tool.deltas.length; index++) { + pushPrivateArray(events, { type: "tool-input-delta", toolCallId: event.toolCallId, - inputTextDelta: delta, + inputTextDelta: tool.deltas[index]!, }); } if (tool) tool.announced = true; @@ -125,7 +129,7 @@ export function createStreamLifecycleLiveAdapter( }); } } - events.push({ + pushPrivateArray(events, { type: "tool-input-available", toolCallId: event.toolCallId, toolName: event.toolName, diff --git a/src/agent/streaming/lifecycle/reducer.test.ts b/src/agent/streaming/lifecycle/reducer.test.ts index dee4eecc61..cc679600cd 100644 --- a/src/agent/streaming/lifecycle/reducer.test.ts +++ b/src/agent/streaming/lifecycle/reducer.test.ts @@ -19,6 +19,71 @@ function reduceEvents(events: readonly StreamProtocolEvent[]) { } describe("stream lifecycle reducer", () => { + for (const target of ["reasoning", "tools", "snapshot deltas", "map deltas"] as const) { + it(`clones ${target} without dispatching through own collection hooks`, () => { + const state = reduceEvents([ + { type: "reasoning_content", id: "r1", delta: "private reasoning" }, + { type: "tool_input_start", toolCallId: "t1", toolName: "inspect" }, + { type: "tool_input_content", toolCallId: "t1", delta: '{"text":"private input"}' }, + ]); + state.snapshot.tools[0]!.inputDeltas = [...state.snapshot.tools[0]!.inputDeltas]; + const values = target === "reasoning" + ? state.snapshot.reasoning + : target === "tools" + ? state.snapshot.tools + : target === "snapshot deltas" + ? state.snapshot.tools[0]!.inputDeltas + : state.tools.get("t1")!.inputDeltas; + const method = target === "reasoning" || target === "tools" ? "map" : Symbol.iterator; + const original = values[method]; + let observations = 0; + Object.defineProperty(values, method, { + value: function (...args: unknown[]) { + observations++; + return Reflect.apply(original, values, args); + }, + }); + + const reduced = reduceStreamSignal(state, protocol({ type: "step_start" }), 10); + + assertEquals(observations, 0); + assertEquals(reduced.state.snapshot.reasoning, [{ id: "r1", text: "private reasoning" }]); + assertEquals(reduced.state.snapshot.tools[0]!.inputDeltas, ['{"text":"private input"}']); + assertEquals(reduced.state.tools.get("t1")!.inputDeltas, ['{"text":"private input"}']); + assertEquals(reduced.frames[0]!.event.type, "step_start"); + }); + } + + it("isolates cloned records and deltas while retaining tool payload identity and order", () => { + const input = { nested: { text: "private input" } }; + const state = reduceEvents([ + { type: "reasoning_content", id: "r1", delta: "private reasoning" }, + { type: "tool_input_start", toolCallId: "b", toolName: "inspect" }, + { type: "tool_input_content", toolCallId: "b", delta: '{"nested":' }, + { type: "tool_input_ready", toolCallId: "b", toolName: "inspect", input }, + { type: "tool_input_start", toolCallId: "a", toolName: "inspect" }, + ]); + const cloned = reduceStreamSignal(state, protocol({ type: "step_start" }), 10).state; + assertEquals(cloned.snapshot.tools.map((tool) => tool.id), ["b", "a"]); + assertEquals([...cloned.tools.keys()], ["b", "a"]); + assertEquals(cloned.tools.get("b")!.input === input, true); + assertEquals(cloned.snapshot.tools[0]!.input === input, true); + assertEquals( + cloned.snapshot.tools[0]!.inputDeltas === cloned.tools.get("b")!.inputDeltas, + false, + ); + cloned.snapshot.reasoning[0]!.text = "changed"; + (cloned.snapshot.tools[0]!.inputDeltas as string[]).push("snapshot change"); + (cloned.tools.get("b")!.inputDeltas as string[]).push("map change"); + cloned.snapshot.usage.inputTokens = 10; + assertEquals(state.snapshot.reasoning[0]!.text, "private reasoning"); + assertEquals(state.snapshot.tools[0]!.inputDeltas, ['{"nested":']); + assertEquals(state.tools.get("b")!.inputDeltas, ['{"nested":']); + assertEquals(state.snapshot.usage.inputTokens, 0); + assertEquals(cloned.snapshot.tools[0]!.inputDeltas, ['{"nested":', "snapshot change"]); + assertEquals(cloned.tools.get("b")!.inputDeltas, ['{"nested":', "map change"]); + }); + it("balances reasoning before text and creates a new text identity after end", () => { let state = createInitialReducerState(); const events = [ diff --git a/src/agent/streaming/lifecycle/reducer.ts b/src/agent/streaming/lifecycle/reducer.ts index 18971ae137..3abe8a6c3a 100644 --- a/src/agent/streaming/lifecycle/reducer.ts +++ b/src/agent/streaming/lifecycle/reducer.ts @@ -1,4 +1,12 @@ import { privateJsonStringify } from "#veryfront/security/private-json.ts"; +import { + concatPrivateArrays, + filterPrivateArray, + mapPrivateArray, + pushPrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { mergeToolCallInput, mergeToolInputDelta, @@ -48,7 +56,7 @@ export function createInitialReducerState(): StreamReducerState { activeTextId: null, activeReasoningId: null, nextTextIndex: 0, - tools: new Map(), + tools: createPrivateMap(), terminal: false, terminalError: null, }; @@ -65,7 +73,8 @@ export function reduceStreamSignal( const emit = ( frame: Omit, ) => - frames.push( + pushPrivateArray( + frames, { ...frame, sequence: ++state.sequence, elapsedMs } as StreamLifecycleFrame, ); @@ -133,13 +142,13 @@ export function reduceStreamSignal( event: { type: "protocol_repair", code: "implicit_reasoning_start" }, }); } - const reasoning = [...state.snapshot.reasoning]; - const index = reasoning.findIndex((part) => part.id === id); - const prior = (index >= 0 ? reasoning[index] : undefined) ?? - { id, text: "" }; + const reasoning = mapPrivateArray(state.snapshot.reasoning, (part) => part); + let index = 0; + while (index < reasoning.length && reasoning[index]!.id !== id) index++; + const prior = index < reasoning.length ? reasoning[index]! : { id, text: "" }; const updated = { ...prior, text: prior.text + delta }; - if (index >= 0) reasoning[index] = updated; - else reasoning.push(updated); + if (index < reasoning.length) reasoning[index] = updated; + else pushPrivateArray(reasoning, updated); state.snapshot = { ...state.snapshot, reasoning }; emit({ class: "semantic", event: signal.event }); if (delta.length > 0) markProgress(); @@ -151,15 +160,14 @@ export function reduceStreamSignal( if (signature !== undefined || redactedData !== undefined) { state.snapshot = { ...state.snapshot, - reasoning: state.snapshot.reasoning.map((part) => + reasoning: mapPrivateArray(state.snapshot.reasoning, (part) => part.id === id ? { ...part, ...(signature !== undefined ? { signature } : {}), ...(redactedData !== undefined ? { redactedData } : {}), } - : part - ), + : part), }; } emit({ class: "semantic", event: signal.event }); @@ -247,23 +255,22 @@ type FrameEmitter = ( ) => void; function cloneReducerState(current: StreamReducerState): StreamReducerState { + const tools = createPrivateMap(); + for (const [id, tool] of current.tools) { + tools.set(id, { ...tool, inputDeltas: mapPrivateArray(tool.inputDeltas, (delta) => delta) }); + } return { ...current, snapshot: { ...current.snapshot, - reasoning: current.snapshot.reasoning.map((part) => ({ ...part })), - tools: current.snapshot.tools.map((tool) => ({ + reasoning: mapPrivateArray(current.snapshot.reasoning, (part) => ({ ...part })), + tools: mapPrivateArray(current.snapshot.tools, (tool) => ({ ...tool, - inputDeltas: [...tool.inputDeltas], + inputDeltas: mapPrivateArray(tool.inputDeltas, (delta) => delta), })), usage: { ...current.snapshot.usage }, }, - tools: new Map( - [...current.tools].map(([id, tool]) => [id, { - ...tool, - inputDeltas: [...tool.inputDeltas], - }]), - ), + tools, terminalError: current.terminalError ? { ...current.terminalError } : null, }; } @@ -312,7 +319,7 @@ function reduceNonTextProtocolEvent( ...tool, phase: "input_streaming", inputText, - inputDeltas: [...tool.inputDeltas, event.delta], + inputDeltas: concatPrivateArrays(tool.inputDeltas, [event.delta]), }); syncToolSnapshot(state, "awaiting_tool_input"); emit({ class: "semantic", event }); @@ -434,11 +441,13 @@ function reduceNonTextProtocolEvent( commitPendingLocalInputs(state, emit, elapsedMs); } emit({ class: "semantic", event }); - const readyLocal = [...state.tools.values()].filter((tool) => - tool.phase === "input_ready" && tool.providerExecuted !== true + const readyLocal = filterPrivateArray( + [...state.tools.values()], + (tool) => tool.phase === "input_ready" && tool.providerExecuted !== true, ); - const rejectedLocal = [...state.tools.values()].filter((tool) => - tool.phase === "input_rejected" && tool.providerExecuted !== true + const rejectedLocal = filterPrivateArray( + [...state.tools.values()], + (tool) => tool.phase === "input_rejected" && tool.providerExecuted !== true, ); const phaseBeforeFinish = state.snapshot.phase; const terminalPhase = event.finishReason === "tool-calls" && readyLocal.length > 0 @@ -456,9 +465,11 @@ function reduceNonTextProtocolEvent( hasSemanticProgress: true, }; if (terminalPhase === "failed") { - const incomplete = rejectedLocal.some((tool) => - tool.rejectionReason === "invalid" || - tool.rejectionReason === "malformed" + const incomplete = somePrivateArray( + rejectedLocal, + (tool) => + tool.rejectionReason === "invalid" || + tool.rejectionReason === "malformed", ); state.terminalError = { code: incomplete ? "TOOL_INPUT_INCOMPLETE" : "PROTOCOL_VIOLATION", @@ -522,9 +533,9 @@ function syncToolSnapshot( state.snapshot = { ...state.snapshot, phase, - tools: [...state.tools.values()].map((tool) => ({ ...tool })), + tools: mapPrivateArray([...state.tools.values()], (tool) => ({ ...tool })), hasStreamOutput: state.snapshot.hasStreamOutput || - [...state.tools.values()].some((tool) => tool.rejectionReason !== "unavailable"), + somePrivateArray([...state.tools.values()], (tool) => tool.rejectionReason !== "unavailable"), }; } @@ -634,7 +645,8 @@ export function resolveLocalToolDeadline( const state = cloneReducerState(current); const frames: StreamLifecycleFrame[] = []; const emit: FrameEmitter = (frame) => - frames.push( + pushPrivateArray( + frames, { ...frame, sequence: ++state.sequence, elapsedMs } as StreamLifecycleFrame, ); @@ -702,8 +714,9 @@ export function resolveLocalToolDeadline( } } - const ready = [...state.tools.values()].filter((tool) => - tool.phase === "input_ready" && tool.providerExecuted !== true + const ready = filterPrivateArray( + [...state.tools.values()], + (tool) => tool.phase === "input_ready" && tool.providerExecuted !== true, ); state.terminal = true; if (ready.length > 0) { @@ -712,13 +725,13 @@ export function resolveLocalToolDeadline( finishReason: "tool-calls", phase: "tool_handoff", hasSemanticProgress: true, - tools: [...state.tools.values()].map((tool) => ({ ...tool })), + tools: mapPrivateArray([...state.tools.values()], (tool) => ({ ...tool })), }; return { kind: "handoff", reduction: { state, frames, semanticProgress: true } }; } state.snapshot = { ...state.snapshot, - tools: [...state.tools.values()].map((tool) => ({ ...tool })), + tools: mapPrivateArray([...state.tools.values()], (tool) => ({ ...tool })), }; return { kind: "failed", @@ -739,7 +752,8 @@ export function finalizeStreamProjection( const state = cloneReducerState(current); const frames: StreamLifecycleFrame[] = []; const emit: FrameEmitter = (frame) => { - frames.push( + pushPrivateArray( + frames, { ...frame, sequence: ++state.sequence, elapsedMs } as StreamLifecycleFrame, ); }; diff --git a/src/chat/json-value.test.ts b/src/chat/json-value.test.ts index 131ee8b2a8..7540171c95 100644 --- a/src/chat/json-value.test.ts +++ b/src/chat/json-value.test.ts @@ -3,6 +3,21 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { stringifyChatJson, toChatJsonValue } from "./json-value.ts"; describe("chat JSON normalization", () => { + it("serializes within caller-selected limits without changing the input graph", () => { + const values = Array.from({ length: 100_001 }, (_, index) => index); + const options = { maxNodes: 100_002, maxContainerEntries: 100_001 }; + const parsed = JSON.parse(stringifyChatJson(values, options)); + assertEquals(parsed.length, values.length); + assertEquals(parsed[100_000], 100_000); + assertEquals(Object.getPrototypeOf(values), Array.prototype); + const nested: Record = {}; + let cursor = nested; + for (let index = 0; index < 130; index++) { + cursor.child = {}; + cursor = cursor.child as Record; + } + assertEquals(stringifyChatJson(nested, { maxDepth: 140 }), JSON.stringify(nested)); + }); it("is cycle-safe and does not invoke accessors", () => { let getterCalls = 0; const value: Record = { diff --git a/src/chat/json-value.ts b/src/chat/json-value.ts index be100e0df8..7b4b631989 100644 --- a/src/chat/json-value.ts +++ b/src/chat/json-value.ts @@ -8,6 +8,33 @@ */ import { compareStrings } from "#veryfront/utils/compare.ts"; +import { filterPrivateArray, pushPrivateArray } from "#veryfront/security/private-array.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { privateTextSlice } from "#veryfront/security/private-text.ts"; + +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; +const ownKeys = Reflect.ownKeys; +const apply = Reflect.apply; +const arraySort = Array.prototype.sort; +const isArray = Array.isArray; +const isSafeInteger = Number.isSafeInteger; +const isFiniteNumber = Number.isFinite; +const objectIs = Object.is; +const minimum = Math.min; +const NativeWeakSet = WeakSet; +const weakSetHas = WeakSet.prototype.has; +const weakSetAdd = WeakSet.prototype.add; +const weakSetDelete = WeakSet.prototype.delete; +const NativeDate = Date; +const NativeURL = URL; +const dateGetTime = Date.prototype.getTime; +const dateToISOString = Date.prototype.toISOString; +const urlToString = URL.prototype.toString; +const bigintToString = BigInt.prototype.toString; +const stringify = JSON.stringify; +const setPrototypeOf = Object.setPrototypeOf; +const objectKeys = Object.keys; const DEFAULT_MAX_DEPTH = 64; const DEFAULT_MAX_NODES = 65_536; const DEFAULT_MAX_STRING_CHARS = 8 * 1024 * 1024; @@ -55,7 +82,7 @@ function readLimit( minimum: number, ): number { const resolved = value ?? fallback; - if (!Number.isSafeInteger(resolved) || resolved < minimum) { + if (!isSafeInteger(resolved) || resolved < minimum) { throw new TypeError(`Chat JSON ${name} must be a safe integer no less than ${minimum}`); } return resolved; @@ -93,9 +120,9 @@ function boundedString(value: string, state: ConversionState): string { const suffix = "… [truncated]"; state.stringChars = state.maxStringChars; if (remaining <= suffix.length) { - return value.slice(0, remaining); + return privateTextSlice(value, 0, remaining); } - return `${value.slice(0, remaining - suffix.length)}${suffix}`; + return `${privateTextSlice(value, 0, remaining - suffix.length)}${suffix}`; } function beginValue(depth: number, state: ConversionState): boolean { @@ -111,7 +138,7 @@ function readDescriptor( key: PropertyKey, ): PropertyDescriptor | undefined { try { - return Object.getOwnPropertyDescriptor(value, key); + return getOwnPropertyDescriptor(value, key); } catch { return undefined; } @@ -123,28 +150,28 @@ function convertArray( state: ConversionState, ): ChatJsonValue[] { const lengthDescriptor = readDescriptor(value, "length"); - const length = lengthDescriptor && Object.hasOwn(lengthDescriptor, "value") && - Number.isSafeInteger(lengthDescriptor.value) && lengthDescriptor.value >= 0 + const length = lengthDescriptor && hasOwn(lengthDescriptor, "value") && + isSafeInteger(lengthDescriptor.value) && lengthDescriptor.value >= 0 ? lengthDescriptor.value as number : 0; - const itemCount = Math.min(length, state.maxContainerEntries); + const itemCount = minimum(length, state.maxContainerEntries); const output: ChatJsonValue[] = []; for (let index = 0; index < itemCount; index += 1) { const descriptor = readDescriptor(value, String(index)); if (!descriptor) { - output.push(null); + pushPrivateArray(output, null); continue; } - if (!Object.hasOwn(descriptor, "value")) { - output.push(ACCESSOR_MARKER); + if (!hasOwn(descriptor, "value")) { + pushPrivateArray(output, ACCESSOR_MARKER); continue; } - output.push(convertValue(descriptor.value, depth + 1, state)); + pushPrivateArray(output, convertValue(descriptor.value, depth + 1, state)); } if (length > itemCount) { - output.push(`${TRUNCATED_MARKER} ${length - itemCount} array items`); + pushPrivateArray(output, `${TRUNCATED_MARKER} ${length - itemCount} array items`); } return output; } @@ -154,10 +181,9 @@ function defineJsonProperty( key: string, value: ChatJsonValue, ): void { - Object.defineProperty(target, key, { + defineOwnDataProperty(target, key, value, { configurable: true, enumerable: true, - value, writable: true, }); } @@ -169,16 +195,17 @@ function convertObject( ): { [key: string]: ChatJsonValue } | string { let keys: string[]; try { - keys = Reflect.ownKeys(value) - .filter((key): key is string => typeof key === "string") - .filter((key) => readDescriptor(value, key)?.enumerable === true) - .sort(compareStrings); + keys = filterPrivateArray( + filterPrivateArray(ownKeys(value), (key): key is string => typeof key === "string"), + (key) => readDescriptor(value, key)?.enumerable === true, + ); + apply(arraySort, keys, [compareStrings]); } catch { return UNREADABLE_MARKER; } const output: Record = {}; - const candidateCount = Math.min(keys.length, state.maxContainerEntries); + const candidateCount = minimum(keys.length, state.maxContainerEntries); let entryCount = 0; for (let index = 0; index < candidateCount; index += 1) { const key = keys[index]!; @@ -189,7 +216,7 @@ function convertObject( const descriptor = readDescriptor(value, key); if (!descriptor) { defineJsonProperty(output, key, UNREADABLE_MARKER); - } else if (!Object.hasOwn(descriptor, "value")) { + } else if (!hasOwn(descriptor, "value")) { defineJsonProperty(output, key, ACCESSOR_MARKER); } else { defineJsonProperty(output, key, convertValue(descriptor.value, depth + 1, state)); @@ -199,7 +226,7 @@ function convertObject( if (keys.length > entryCount) { let markerKey = "__veryfront_truncated__"; - while (Object.hasOwn(output, markerKey)) markerKey = `_${markerKey}`; + while (hasOwn(output, markerKey)) markerKey = `_${markerKey}`; defineJsonProperty(output, markerKey, `${keys.length - entryCount} object entries omitted`); } return output; @@ -210,14 +237,14 @@ function convertKnownScalarObject( state: ConversionState, ): ChatJsonValue | undefined { try { - if (value instanceof Date) { - const timestamp = Date.prototype.getTime.call(value); - return Number.isFinite(timestamp) - ? boundedString(Date.prototype.toISOString.call(value), state) + if (value instanceof NativeDate) { + const timestamp = apply(dateGetTime, value, []) as number; + return isFiniteNumber(timestamp) + ? boundedString(apply(dateToISOString, value, []) as string, state) : null; } - if (value instanceof URL) { - return boundedString(URL.prototype.toString.call(value), state); + if (value instanceof NativeURL) { + return boundedString(apply(urlToString, value, []) as string, state); } } catch { return UNREADABLE_MARKER; @@ -242,9 +269,9 @@ function convertValue( case "boolean": return value; case "number": - return Number.isFinite(value) ? (Object.is(value, -0) ? 0 : value) : null; + return isFiniteNumber(value) ? (objectIs(value, -0) ? 0 : value) : null; case "bigint": - return boundedString(value.toString(), state); + return boundedString(apply(bigintToString, value, []) as string, state); case "undefined": case "function": case "symbol": @@ -254,7 +281,7 @@ function convertValue( } const objectValue = value as object; - if (state.ancestors.has(objectValue)) { + if (apply(weakSetHas, state.ancestors, [objectValue])) { return CIRCULAR_MARKER; } @@ -263,15 +290,15 @@ function convertValue( return scalar; } - state.ancestors.add(objectValue); + apply(weakSetAdd, state.ancestors, [objectValue]); try { - return Array.isArray(value) + return isArray(value) ? convertArray(value, depth, state) : convertObject(objectValue, depth, state); } catch { return UNREADABLE_MARKER; } finally { - state.ancestors.delete(objectValue); + apply(weakSetDelete, state.ancestors, [objectValue]); } } @@ -288,7 +315,7 @@ export function toChatJsonValue( const resolved = resolveOptions(options); return convertValue(value, 0, { ...resolved, - ancestors: new WeakSet(), + ancestors: new NativeWeakSet(), nodes: 0, stringChars: 0, }); @@ -299,5 +326,16 @@ export function stringifyChatJson( value: unknown, options: ChatJsonValueOptions = {}, ): string { - return JSON.stringify(toChatJsonValue(value, options)); + const normalized = toChatJsonValue(value, options); + const protect = (value: ChatJsonValue): void => { + if (value === null || typeof value !== "object") return; + setPrototypeOf(value, null); + const keys = objectKeys(value); + for (let index = 0; index < keys.length; index++) { + const descriptor = getOwnPropertyDescriptor(value, keys[index]!)!; + protect(descriptor.value as ChatJsonValue); + } + }; + protect(normalized); + return stringify(normalized); } diff --git a/src/security/private-array.test.ts b/src/security/private-array.test.ts index 1a2d01214a..7017b83566 100644 --- a/src/security/private-array.test.ts +++ b/src/security/private-array.test.ts @@ -9,9 +9,38 @@ import { flatMapPrivateArray, joinPrivateArray, pushPrivateArray, + slicePrivateArray, } from "./private-array.ts"; describe("private array concatenation", () => { + it("copies ranges and holes without invoking slice or inherited index getters", () => { + const value = { text: "private range" }; + const values = [value, , value]; + let observations = 0; + Object.setPrototypeOf( + values, + Object.create(Array.prototype, { + 1: { + get() { + observations++; + return value; + }, + }, + slice: { + get() { + observations++; + return Array.prototype.slice; + }, + }, + }), + ); + assertEquals(slicePrivateArray(values, -2), [, value]); + assertEquals(slicePrivateArray(values, 1, 1), []); + assertEquals(slicePrivateArray(values, -Infinity, Infinity), [value, , value]); + assertEquals(slicePrivateArray(values, NaN, 1.9), [value]); + assertStrictEquals(slicePrivateArray(values)[0], value); + assertEquals(observations, 0); + }); it("checks and reverse-scans own entries with early termination", () => { const values = [1, , 3]; let observations = 0; diff --git a/src/security/private-array.ts b/src/security/private-array.ts index df56bc5fc5..51bdbafd42 100644 --- a/src/security/private-array.ts +++ b/src/security/private-array.ts @@ -3,6 +3,31 @@ import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts" const hasOwn = Object.hasOwn; const isArray = Array.isArray; const apply = Reflect.apply; +const truncate = Math.trunc; +const minimum = Math.min; +const maximum = Math.max; + +/** Copy an array range without consulting slice, species, or inherited entries. */ +export function slicePrivateArray(values: readonly T[], start = 0, end = values.length): T[] { + const length = values.length; + const normalize = (offset: number) => { + const index = truncate(offset) || 0; + return index < 0 ? maximum(length + index, 0) : minimum(index, length); + }; + const first = normalize(start); + const last = normalize(end); + const output: T[] = []; + output.length = maximum(last - first, 0); + for (let index = first; index < last; index++) { + if (!hasOwn(values, index)) continue; + defineOwnDataProperty(output, index - first, values[index], { + enumerable: true, + configurable: true, + writable: true, + }); + } + return output; +} /** Require every own entry to pass, without consulting mutable array methods. */ export function everyPrivateArray( diff --git a/tests/integration/agent/lifecycle-collection-intrinsics.test.ts b/tests/integration/agent/lifecycle-collection-intrinsics.test.ts new file mode 100644 index 0000000000..1ee5f0adb8 --- /dev/null +++ b/tests/integration/agent/lifecycle-collection-intrinsics.test.ts @@ -0,0 +1,180 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createStreamLifecycleLiveAdapter } from "#veryfront/agent/streaming/lifecycle/live-adapter.ts"; +import { + createInitialReducerState, + finalizeStreamProjection, + reduceStreamSignal, + resolveLocalToolDeadline, +} from "#veryfront/agent/streaming/lifecycle/reducer.ts"; +import type { + StreamLifecycleFrame, + StreamProtocolEvent, +} from "#veryfront/agent/streaming/lifecycle/types.ts"; +import type { ChatStreamEvent } from "#veryfront/chat/protocol.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +// Shared-intrinsic variants run in CI. Local runs must explicitly filter to baseline. +for ( + const probe of ["baseline", "array methods", "array iterator", "map methods", "map constructor"] +) { + describe(`private lifecycle collections ${probe}`, () => { + it("retains reasoning, ordered tool deltas, and deadline frames without collection hooks", () => { + const marker = "synthetic-private-lifecycle-content"; + const input = { text: marker }; + const events: StreamProtocolEvent[] = [ + { type: "reasoning_content", id: "r1", delta: marker }, + { type: "reasoning_content", id: "r1", delta: "!" }, + { type: "reasoning_end", id: "r1", signature: marker }, + { type: "text_content", delta: marker }, + { type: "tool_input_start", toolCallId: "b", toolName: "inspect" }, + { type: "tool_input_content", toolCallId: "b", delta: '{"text":' }, + { type: "tool_input_content", toolCallId: "b", delta: `"${marker}"}` }, + { type: "tool_input_start", toolCallId: "a", toolName: "inspect" }, + { type: "tool_input_content", toolCallId: "a", delta: '{"text":' }, + ]; + const NativeMap = Map; + const arrayIterator = Array.prototype[Symbol.iterator]; + const methodNames = ["push", "map", "filter", "some", "findIndex"] as const; + const arrayMethods = methodNames.map((name) => Array.prototype[name]); + const mapNames = ["get", "set", "delete", "values", "entries"] as const; + const mapMethods = mapNames.map((name) => Map.prototype[name]); + const mapIterator = Map.prototype[Symbol.iterator]; + const apply = Reflect.apply; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const observe = (value: unknown) => { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + }; + let observations = 0; + let state = createInitialReducerState(); + let finished: typeof state | undefined; + let projected: StreamLifecycleFrame[] = []; + let emitted: ChatStreamEvent[] = []; + let deadline: ReturnType | undefined; + let failed: ReturnType | undefined; + try { + if (probe === "array methods") { + for (let index = 0; index < methodNames.length; index++) { + const name = methodNames[index]!; + const original = arrayMethods[index]!; + Object.defineProperty(Array.prototype, name, { + configurable: true, + writable: true, + value: function (this: unknown[], ...args: unknown[]) { + observe(this); + observe(args); + return apply(original, this, args); + }, + }); + } + } + if (probe === "array iterator") { + Array.prototype[Symbol.iterator] = function () { + observe(this); + return apply(arrayIterator, this, []); + }; + } + if (probe === "map methods") { + for (let index = 0; index < mapNames.length; index++) { + const name = mapNames[index]!; + const original = mapMethods[index]!; + Object.defineProperty(Map.prototype, name, { + configurable: true, + writable: true, + value: function (this: Map, ...args: unknown[]) { + observations++; + return apply(original, this, args); + }, + }); + } + Map.prototype[Symbol.iterator] = function () { + observations++; + return apply(mapIterator, this, []); + }; + } + if (probe === "map constructor") { + globalThis.Map = new Proxy(NativeMap, { + construct(target, args, newTarget) { + observations++; + return Reflect.construct(target, args, newTarget); + }, + }); + } + state = createInitialReducerState(); + const adapter = createStreamLifecycleLiveAdapter({}); + for (let index = 0; index < events.length; index++) { + const reduction = reduceStreamSignal( + state, + { kind: "protocol", event: events[index]! }, + index, + ); + state = reduction.state; + for (let frame = 0; frame < reduction.frames.length; frame++) { + adapter.encode(reduction.frames[frame]!); + } + } + deadline = resolveLocalToolDeadline(state, "tool_input_idle", 10); + for (let index = 0; index < deadline.reduction.frames.length; index++) { + const encoded = adapter.encode(deadline.reduction.frames[index]!); + if (encoded[0]?.type === "tool-input-start") emitted = encoded; + } + finished = reduceStreamSignal(state, { + kind: "protocol", + event: { type: "step_finish", finishReason: "tool-calls" }, + }, 11).state; + const text = reduceStreamSignal(createInitialReducerState(), { + kind: "protocol", + event: { type: "text_content", delta: marker }, + }, 12); + projected = finalizeStreamProjection(text.state, 13).frames; + const incomplete = reduceStreamSignal(createInitialReducerState(), { + kind: "protocol", + event: { type: "tool_input_start", toolCallId: "empty", toolName: "inspect" }, + }, 14); + failed = resolveLocalToolDeadline(incomplete.state, "tool_commit_grace", 15); + } finally { + if (probe === "array methods") { + for (let index = 0; index < methodNames.length; index++) { + Object.defineProperty(Array.prototype, methodNames[index]!, { + configurable: true, + writable: true, + value: arrayMethods[index], + }); + } + } + if (probe === "array iterator") Array.prototype[Symbol.iterator] = arrayIterator; + if (probe === "map methods") { + for (let index = 0; index < mapNames.length; index++) { + Object.defineProperty(Map.prototype, mapNames[index]!, { + configurable: true, + writable: true, + value: mapMethods[index], + }); + } + Map.prototype[Symbol.iterator] = mapIterator; + } + if (probe === "map constructor") globalThis.Map = NativeMap; + } + assertEquals(observations, 0); + assertEquals(state.snapshot.reasoning, [{ id: "r1", text: `${marker}!`, signature: marker }]); + assertEquals(state.snapshot.accumulatedText, marker); + assertEquals(deadline?.kind, "handoff"); + assertEquals(deadline?.reduction.state.snapshot.tools.map((tool) => [tool.id, tool.phase]), [ + ["b", "input_ready"], + ["a", "input_rejected"], + ]); + assertEquals(deadline?.reduction.state.snapshot.tools[0]?.input, input); + assertEquals(finished?.snapshot.phase, "tool_handoff"); + assertEquals(emitted, [ + { type: "tool-input-start", toolCallId: "b", toolName: "inspect" }, + { type: "tool-input-delta", toolCallId: "b", inputTextDelta: '{"text":' }, + { type: "tool-input-delta", toolCallId: "b", inputTextDelta: `"${marker}"}` }, + { type: "tool-input-available", toolCallId: "b", toolName: "inspect", input }, + ]); + assertEquals(projected.map((frame) => frame.event.type), ["text_end"]); + assertEquals(failed?.kind, "failed"); + assertEquals(failed?.kind === "failed" ? failed.code : null, "TOOL_INPUT_TIMEOUT"); + }); + }); +} diff --git a/tests/integration/agent/replay-checkpoint-collection-intrinsics.test.ts b/tests/integration/agent/replay-checkpoint-collection-intrinsics.test.ts new file mode 100644 index 0000000000..342ee3c96b --- /dev/null +++ b/tests/integration/agent/replay-checkpoint-collection-intrinsics.test.ts @@ -0,0 +1,90 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + applyProviderReplayCheckpointsToMessages, + captureProviderReplayCheckpoint, + createProviderReplayCheckpointEmissionState, + type ProviderReplayCheckpoint, +} from "#veryfront/agent/runtime/provider-replay.ts"; +import { readAttachedProviderMetadata } from "#veryfront/agent/runtime/provider-metadata.ts"; +import type { Message } from "#veryfront/agent/types.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "append", "iteration", "projection", "reflection"]) { + describe(`private replay checkpoint collections ${probe}`, () => { + it("captures cumulative reasoning and restores its checkpoint without shared collection hooks", () => { + const marker = "synthetic-private-checkpoint-block"; + const thinking = { type: "thinking", thinking: marker, signature: "synthetic-signature" }; + const text = { type: "text" as const, text: marker }; + const first = { anthropic: { rawAssistantMessages: [[thinking]] } }; + const second = { anthropic: { rawAssistantMessages: [[text]] } }; + const messages: Message[] = [{ + id: "assistant", + role: "assistant", + parts: [{ type: "reasoning", text: marker, signature: thinking.signature }, text], + }]; + const state = createProviderReplayCheckpointEmissionState({ messageId: "assistant" }); + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; + let observations = 0; + const observe = (value: unknown) => { + try { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + } catch { /* Preserve normal delegation for unrelated non-JSON values. */ } + }; + const replace = (target: object, key: PropertyKey, args = false) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...values: unknown[]) { + observe(args ? values : this); + return apply(descriptor.value, this, values); + }, + }); + }; + let checkpoint: ProviderReplayCheckpoint | undefined; + let restored: ReturnType | undefined; + try { + if (probe === "append") replace(Array.prototype, "push", true); + if (probe === "iteration") replace(Array.prototype, Symbol.iterator); + if (probe === "projection") { + for (const key of ["some", "map", "filter", "flat", "flatMap", "slice"]) { + replace(Array.prototype, key); + } + } + if (probe === "reflection") { + replace(Object, "keys", true); + replace(Object, "entries", true); + replace(Reflect, "ownKeys", true); + replace(Array, "isArray", true); + } + captureProviderReplayCheckpoint(state, first); + checkpoint = captureProviderReplayCheckpoint(state, second); + restored = createProviderReplayCheckpointEmissionState({ + messageId: "assistant", + existingCheckpoint: checkpoint, + }); + applyProviderReplayCheckpointsToMessages(messages, [checkpoint!], { + activeProvider: "anthropic", + }); + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(checkpoint?.providerMessageBlockCounts, [1, 1]); + assertEquals(checkpoint?.providerBlocks.map((entry) => entry.block), [thinking, text]); + assertEquals(restored?.rawAssistantMessages, [[thinking], [text]]); + assertEquals(restored?.replayRequired, true); + assertEquals(readAttachedProviderMetadata(messages[0]!)?.anthropic, { + rawAssistantMessages: [[thinking], [text]], + }); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/streamed-assistant-assembly-intrinsics.test.ts b/tests/integration/agent/streamed-assistant-assembly-intrinsics.test.ts new file mode 100644 index 0000000000..365cddae62 --- /dev/null +++ b/tests/integration/agent/streamed-assistant-assembly-intrinsics.test.ts @@ -0,0 +1,100 @@ +import "#veryfront/schemas/_test-setup.ts"; +import type { StreamingToolCall } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { buildStreamedAssistantMessage } from "#veryfront/agent/runtime/streamed-assistant-message.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "push", "iterator"] as const) { + describe(`private streamed assistant assembly ${probe}`, () => { + it("preserves reasoning, text, and tool arguments without exposing private arrays", () => { + const marker = "synthetic-private-streamed-assembly"; + const reasoningParts = [ + { id: "empty", text: "", signature: "", redactedData: "" }, + { id: "text", text: `${marker}-reasoning`, signature: `${marker}-signature` }, + { id: "signature", text: "", signature: `${marker}-signature-only` }, + { id: "redacted", text: "", redactedData: `${marker}-redacted` }, + ]; + const toolCalls = createPrivateMap(); + toolCalls.set("call", { + id: "call", + name: "lookup", + arguments: '{"query":"synthetic-private-streamed-assembly-arguments"}', + inputAvailable: true, + }); + toolCalls.set("placeholder", { + id: "placeholder", + name: "suggestions", + arguments: "{}", + inputAvailable: false, + }); + const state = { accumulatedText: `${marker}-answer`, reasoningParts, toolCalls }; + const identity = { id: "assistant", timestamp: 123 }; + const push = Array.prototype.push; + const iterator = Array.prototype[Symbol.iterator]; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let message: ReturnType | undefined; + let preserved: typeof message; + try { + if (probe === "push") { + Array.prototype.push = function (...items) { + for (let index = 0; index < items.length; index++) { + const serialized = stringify(items[index]); + if (serialized && apply(includes, serialized, [marker])) observations++; + } + return apply(push, this, items); + }; + } else if (probe === "iterator") { + Array.prototype[Symbol.iterator] = function () { + if (this === reasoningParts) observations++; + return apply(iterator, this, []); + }; + } + message = buildStreamedAssistantMessage(state, identity); + preserved = buildStreamedAssistantMessage(state, identity, { + preserveRecoverablePlaceholderToolCalls: true, + }); + } finally { + if (probe === "push") Array.prototype.push = push; + else if (probe === "iterator") Array.prototype[Symbol.iterator] = iterator; + } + const expected: ReturnType = { + id: "assistant", + role: "assistant", + timestamp: 123, + parts: [ + { + type: "reasoning", + text: `${marker}-reasoning`, + signature: `${marker}-signature`, + }, + { type: "reasoning", signature: `${marker}-signature-only` }, + { type: "reasoning", redactedData: `${marker}-redacted` }, + { type: "text", text: `${marker}-answer` }, + { + type: "tool-lookup", + toolCallId: "call", + toolName: "lookup", + args: { query: `${marker}-arguments` }, + inputText: '{"query":"synthetic-private-streamed-assembly-arguments"}', + }, + ], + }; + assertEquals(message, expected); + assertEquals(preserved, { + ...expected, + parts: [...expected.parts, { + type: "tool-suggestions", + toolCallId: "placeholder", + toolName: "suggestions", + args: {}, + inputText: "{}", + }], + }); + assertEquals(observations, 0); + }); + }); +} From e1f82974212a2cfedd7215967bbf0f3fb00bbd7d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 20:20:13 +0200 Subject: [PATCH 156/194] fix(agent): protect raw Anthropic replay traversal --- .../anthropic-provider-replay-block.test.ts | 29 +++++++++++ .../anthropic-provider-replay-block.ts | 51 ++++++++++++------- src/agent/runtime/provider-replay.ts | 8 ++- 3 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 src/agent/runtime/anthropic-provider-replay-block.test.ts diff --git a/src/agent/runtime/anthropic-provider-replay-block.test.ts b/src/agent/runtime/anthropic-provider-replay-block.test.ts new file mode 100644 index 0000000000..a558343593 --- /dev/null +++ b/src/agent/runtime/anthropic-provider-replay-block.test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + collectAnthropicProviderToolCallIds, + groupAnthropicRawAssistantMessagesByAnchor, +} from "./anthropic-provider-replay-block.ts"; + +describe("Anthropic replay block collections", () => { + it("reads provider block groups without consulting their own iterators", () => { + const blocks = [{ + type: "server_tool_use", + id: "call", + input: { text: "synthetic private block" }, + }]; + const groups = [blocks]; + let observations = 0; + for (const value of [groups, blocks]) { + Object.defineProperty(value, Symbol.iterator, { + get() { + observations++; + return Array.prototype[Symbol.iterator]; + }, + }); + } + assertEquals([...collectAnthropicProviderToolCallIds(groups)], ["call"]); + assertEquals(groupAnthropicRawAssistantMessagesByAnchor(groups, 1), [[blocks]]); + assertEquals(observations, 0); + }); +}); diff --git a/src/agent/runtime/anthropic-provider-replay-block.ts b/src/agent/runtime/anthropic-provider-replay-block.ts index a991f6b13e..920e5d63db 100644 --- a/src/agent/runtime/anthropic-provider-replay-block.ts +++ b/src/agent/runtime/anthropic-provider-replay-block.ts @@ -1,7 +1,7 @@ // Anthropic reports provider tool failures with the ordinary outer result type // and the error record inside `content`. An outer `*_tool_result_error` block // would only defer the failure until the provider request parser rejects it. -const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ +const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = createPrivateSet([ "web_search_tool_result", "web_fetch_tool_result", "code_execution_tool_result", @@ -20,31 +20,34 @@ export function groupAnthropicRawAssistantMessagesByAnchor( rawAssistantMessages: unknown, anchorCount: number, ): Record[][][] | undefined { - if (!Array.isArray(rawAssistantMessages)) return undefined; + if (!isArray(rawAssistantMessages)) return undefined; const grouped: Record[][][] = []; let pendingResults: Record[][] = []; - for (const rawAssistantMessage of rawAssistantMessages) { + for (let index = 0; index < rawAssistantMessages.length; index++) { + if (!hasOwn(rawAssistantMessages, index)) return undefined; + const rawAssistantMessage = rawAssistantMessages[index]; if ( - !Array.isArray(rawAssistantMessage) || - !rawAssistantMessage.every((block) => - block !== null && typeof block === "object" && !Array.isArray(block) + !isArray(rawAssistantMessage) || + !everyPrivateArray( + rawAssistantMessage, + (block) => block !== null && typeof block === "object" && !isArray(block), ) ) { return undefined; } const blocks = rawAssistantMessage as Record[]; - if (blocks.length > 0 && blocks.every(isAnthropicProviderToolResultBlock)) { - pendingResults.push(blocks); + if (blocks.length > 0 && everyPrivateArray(blocks, isAnthropicProviderToolResultBlock)) { + pushPrivateArray(pendingResults, blocks); continue; } if (grouped.length >= anchorCount) return undefined; - grouped.push([...pendingResults, blocks]); + pushPrivateArray(grouped, concatPrivateArrays(pendingResults, [blocks])); pendingResults = []; } if (pendingResults.length > 0) { - const finalGroup = grouped.at(-1); + const finalGroup = grouped.length > 0 ? grouped[grouped.length - 1] : undefined; if (!finalGroup) return undefined; - finalGroup.push(...pendingResults); + appendPrivateArray(finalGroup, pendingResults); } return grouped.length === anchorCount ? grouped : undefined; } @@ -53,15 +56,19 @@ export function groupAnthropicRawAssistantMessagesByAnchor( export function collectAnthropicProviderToolCallIds( rawAssistantMessages: unknown, ): Set { - const ids = new Set(); - if (!Array.isArray(rawAssistantMessages)) return ids; - for (const rawAssistantMessage of rawAssistantMessages) { - if (!Array.isArray(rawAssistantMessage)) continue; - for (const block of rawAssistantMessage) { + const ids = createPrivateSet(); + if (!isArray(rawAssistantMessages)) return ids; + for (let index = 0; index < rawAssistantMessages.length; index++) { + if (!hasOwn(rawAssistantMessages, index)) continue; + const rawAssistantMessage = rawAssistantMessages[index]; + if (!isArray(rawAssistantMessage)) continue; + for (let blockIndex = 0; blockIndex < rawAssistantMessage.length; blockIndex++) { + if (!hasOwn(rawAssistantMessage, blockIndex)) continue; + const block = rawAssistantMessage[blockIndex]; if ( block !== null && typeof block === "object" && - !Array.isArray(block) && + !isArray(block) && ((block as Record).type === "server_tool_use" || (block as Record).type === "mcp_tool_use") && typeof (block as Record).id === "string" @@ -72,3 +79,13 @@ export function collectAnthropicProviderToolCallIds( } return ids; } +import { + appendPrivateArray, + concatPrivateArrays, + everyPrivateArray, + pushPrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; + +const isArray = Array.isArray; +const hasOwn = Object.hasOwn; diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 8b1363c582..a252e738c5 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1281,7 +1281,13 @@ function getMessageSegmentForTarget( messages: readonly Message[], target: Message, ): readonly Message[] { - const targetIndex = messages.indexOf(target); + let targetIndex = -1; + for (let index = 0; index < messages.length; index++) { + if (hasOwn(messages, index) && messages[index] === target) { + targetIndex = index; + break; + } + } if (targetIndex === -1) return [target]; let start = targetIndex; while (start > 0) { From 12aa7cf6d0fe215d68a8070ebb38c8b301623f40 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 20:30:58 +0200 Subject: [PATCH 157/194] fix(agent): order Anthropic replay imports before use --- .../anthropic-provider-replay-block.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/agent/runtime/anthropic-provider-replay-block.ts b/src/agent/runtime/anthropic-provider-replay-block.ts index 920e5d63db..19ad336a07 100644 --- a/src/agent/runtime/anthropic-provider-replay-block.ts +++ b/src/agent/runtime/anthropic-provider-replay-block.ts @@ -1,3 +1,14 @@ +import { + appendPrivateArray, + concatPrivateArrays, + everyPrivateArray, + pushPrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; + +const isArray = Array.isArray; +const hasOwn = Object.hasOwn; + // Anthropic reports provider tool failures with the ordinary outer result type // and the error record inside `content`. An outer `*_tool_result_error` block // would only defer the failure until the provider request parser rejects it. @@ -79,13 +90,3 @@ export function collectAnthropicProviderToolCallIds( } return ids; } -import { - appendPrivateArray, - concatPrivateArrays, - everyPrivateArray, - pushPrivateArray, -} from "#veryfront/security/private-array.ts"; -import { createPrivateSet } from "#veryfront/security/private-set.ts"; - -const isArray = Array.isArray; -const hasOwn = Object.hasOwn; From 7a7ae4e0c2e719686fa618d3f508d4c02badb7c4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 20:35:43 +0200 Subject: [PATCH 158/194] fix(agent): protect validation text aggregation and serialization --- .../middleware/security/validator.test.ts | 85 ++++++++ src/agent/middleware/security/validator.ts | 185 +++++++++++------- ...dation-text-aggregation-intrinsics.test.ts | 77 ++++++++ 3 files changed, 278 insertions(+), 69 deletions(-) create mode 100644 tests/integration/agent/validation-text-aggregation-intrinsics.test.ts diff --git a/src/agent/middleware/security/validator.test.ts b/src/agent/middleware/security/validator.test.ts index f78ad7b19f..d9045ebe6f 100644 --- a/src/agent/middleware/security/validator.test.ts +++ b/src/agent/middleware/security/validator.test.ts @@ -228,6 +228,91 @@ describe("OutputFilter", () => { }); describe("securityMiddleware", () => { + it("rejects structured input that cannot be inspected without hiding readable blocked fields", async () => { + let reads = 0; + const accessorInput = { query: "synthetic-blocked-input" }; + Object.defineProperty(accessorInput, "unrelated", { + enumerable: true, + get() { + reads++; + return "harmless"; + }, + }); + for ( + const args of [accessorInput, { + query: "synthetic-blocked-input", + values: new Array(100_001), + }] + ) { + const context = createContext({ + input: [{ + id: "user", + role: "user", + parts: [{ type: "tool-call", toolCallId: "call", toolName: "inspect", args }], + }], + }); + await assertRejects( + () => + securityMiddleware({ input: { blockedPatterns: [/synthetic-blocked-input/] } })( + context, + () => Promise.resolve(createResponse("ok")), + ), + Error, + "Input validation failed", + ); + } + assertEquals(reads, 0); + }); + + it("preserves sparse structured output positions without inherited reads", async () => { + const object = ["first", , "last"]; + let reads = 0; + Object.setPrototypeOf( + object, + Object.create(Array.prototype, { + 1: { + get() { + reads++; + return "inherited"; + }, + }, + }), + ); + const result = await securityMiddleware({})(createContext(), () => + Promise.resolve({ + ...createResponse("ok"), + object, + })); + assertEquals(result.object, ["first", undefined, "last"]); + assertEquals(reads, 0); + }); + it("validates own structured input without invoking its custom JSON serializer", async () => { + let observations = 0; + const args = { query: "synthetic-blocked-input" }; + Object.defineProperty(args, "toJSON", { + value() { + observations++; + return { query: "harmless" }; + }, + }); + const context = createContext({ + input: [{ + id: "user", + role: "user", + parts: [{ type: "tool-call", toolCallId: "call", toolName: "inspect", args }], + }], + }); + await assertRejects( + () => + securityMiddleware({ input: { blockedPatterns: [/synthetic-blocked-input/] } })( + context, + () => Promise.resolve(createResponse("ok")), + ), + Error, + "Input validation failed", + ); + assertEquals(observations, 0); + }); it("validates second-turn membership without consulting the input iterator", async () => { const context = createContext(); await securityMiddleware({ input: { blockedPatterns: [/blocked phrase/] } })( diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index c39ae0b91c..8f58789821 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,5 +1,11 @@ -import { privateTextTrim, privateTextTrimStart } from "#veryfront/security/private-text.ts"; +import { + privateTextSlice, + privateTextTrim, + privateTextTrimStart, +} from "#veryfront/security/private-text.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { privateJsonStringify } from "#veryfront/security/private-json.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { appendPrivateArray, @@ -139,6 +145,8 @@ const PII_REPLACEMENTS: Array<{ pattern: RegExp; label: string }> = [ const RegExpConstructor = RegExp; const hasOwn = Object.hasOwn; +const isArray = Array.isArray; +const objectEntries = Object.entries; const regexpExec = RegExp.prototype.exec; const regexpReplace = RegExp.prototype[Symbol.replace]; const applyRegExp = Reflect.apply; @@ -184,7 +192,7 @@ function redactBlockedPattern(input: string, pattern: RegExp): string { match; match = applyRegExp(regexpExec, matcher, [input]) as RegExpExecArray | null ) { - redacted += `${input.slice(cursor, match.index)}[REDACTED]`; + redacted += `${privateTextSlice(input, cursor, match.index)}[REDACTED]`; cursor = match.index + match[0].length; matched = true; if (!matcher.global) break; @@ -197,7 +205,7 @@ function redactBlockedPattern(input: string, pattern: RegExp): string { } } - return matched ? redacted + input.slice(cursor) : input; + return matched ? redacted + privateTextSlice(input, cursor) : input; } /** @@ -245,7 +253,7 @@ export class InputValidator { pushPrivateArray(violations, { type: "input", reason: `Input exceeds maximum length of ${maxLength}`, - content: `${input.substring(0, 100)}...`, + content: `${privateTextSlice(input, 0, 100)}...`, }); } @@ -384,7 +392,9 @@ function reportViolations( onViolation?: (violation: SecurityViolation) => void, ): void { if (!onViolation) return; - for (const violation of violations) onViolation(violation); + for (let index = 0; index < violations.length; index++) { + if (hasOwn(violations, index)) onViolation(violations[index]!); + } } async function filterStructuredOutputValue( @@ -399,10 +409,11 @@ async function filterStructuredOutputValue( return { value: result.filtered, violations: result.violations }; } - if (Array.isArray(value)) { + if (isArray(value)) { const filteredItems: unknown[] = []; const violations: SecurityViolation[] = []; - for (const item of value) { + for (let itemIndex = 0; itemIndex < value.length; itemIndex++) { + const item = hasOwn(value, itemIndex) ? value[itemIndex] : undefined; const result = await filterStructuredOutputValue(item, outputFilter); pushPrivateArray(filteredItems, result.value); appendPrivateArray(violations, result.violations); @@ -413,9 +424,16 @@ async function filterStructuredOutputValue( if (isRecord(value)) { const filteredObject: Record = {}; const violations: SecurityViolation[] = []; - for (const [key, item] of Object.entries(value)) { + const entries = objectEntries(value); + for (let index = 0; index < entries.length; index++) { + const key = entries[index]![0]; + const item = entries[index]![1]; const result = await filterStructuredOutputValue(item, outputFilter); - filteredObject[key] = result.value; + defineOwnDataProperty(filteredObject, key, result.value, { + enumerable: true, + configurable: true, + writable: true, + }); appendPrivateArray(violations, result.violations); } return { value: filteredObject, violations }; @@ -425,7 +443,7 @@ async function filterStructuredOutputValue( } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } function extractPartInputText(part: unknown): string[] { @@ -437,11 +455,13 @@ function extractPartInputText(part: unknown): string[] { const appendSerialized = (value: unknown) => { if (!isRecord(value)) return; try { - const serialized = JSON.stringify(value); + const serialized = privateJsonStringify(value); if (typeof serialized === "string") pushPrivateArray(values, serialized); } catch { - // Provider converters ignore non-text input on caller-authored user and - // system messages. Unsupported JSON values must not fail the turn here. + throw toError(createError({ + type: "agent", + message: "Input validation failed: Structured input cannot be safely inspected", + })); } }; appendSerialized(part.args); @@ -458,7 +478,7 @@ function extractPartInputText(part: unknown): string[] { * tool messages remain exempt for replay compatibility. The host must supply * trusted replay: this role-based filter does not authenticate message origin. */ -const VALIDATED_INPUT_ROLES: ReadonlySet = new Set(["user", "system"]); +const VALIDATED_INPUT_ROLES: ReadonlySet = createPrivateSet(["user", "system"]); function isTextPart(part: unknown): part is { type: "text"; text: string } { return isRecord(part) && part.type === "text" && typeof part.text === "string"; @@ -513,21 +533,19 @@ function extractMessageAssembledTextsRegardlessOfRole(message: Message): string[ ? getProviderAttachmentMetadata(message.parts) : []; if (textParts.length < 2) { - return [ - ...(message.role === "user" && buildAttachmentContextFromParts(message.parts) - ? textParts - : []), - ...attachmentMetadata, - ]; + return concatPrivateArrays( + message.role === "user" && buildAttachmentContextFromParts(message.parts) ? textParts : [], + attachmentMetadata, + ); } - const assembled = [ - ...mapPrivateArray( + const assembled = concatPrivateArrays( + mapPrivateArray( ASSEMBLED_TEXT_SEPARATORS, (separator) => joinPrivateArray(textParts, separator), ), - ...attachmentMetadata, - ]; + attachmentMetadata, + ); if (message.role === "user" && buildAttachmentContextFromParts(message.parts)) { pushPrivateArray(assembled, getUserTextWithAttachmentContext(message.parts)); } @@ -669,7 +687,9 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { // merging the remaining adjacent system layers. Anthropic retains those // layers, so keep the original runs above and validate this alternate view // in addition. - for (const run of extractAdjacentRuns(messages, "system", true)) { + const alternateRuns = extractAdjacentRuns(messages, "system", true); + for (let index = 0; index < alternateRuns.length; index++) { + const run = alternateRuns[index]!; const alreadyCovered = somePrivateArray(runs, (candidate) => candidate.length === run.length && everyPrivateArray(candidate, (message, index) => message === run[index])); @@ -688,7 +708,7 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { const alreadyCovered = somePrivateArray(runs, (run) => run.length === hoisted.length && everyPrivateArray(run, (message, index) => message === hoisted[index])); - return alreadyCovered ? runs : [...runs, hoisted]; + return alreadyCovered ? runs : concatPrivateArrays(runs, [hoisted]); } /** @@ -698,7 +718,10 @@ function extractMergedSystemRuns(messages: Message[]): Message[][] { * turn. */ function extractMergedRuns(messages: Message[]): Message[][] { - return [...extractMergedSystemRuns(messages), ...extractAdjacentRuns(messages, "user")]; + return concatPrivateArrays( + extractMergedSystemRuns(messages), + extractAdjacentRuns(messages, "user"), + ); } function sameProviderMessageContent(previous: Message, projected: Message): boolean { @@ -707,8 +730,10 @@ function sameProviderMessageContent(previous: Message, projected: Message): bool function createMessageOccurrenceMatcher(previousMessages: Message[], messages: Message[]) { const uniqueById = (values: Message[]): Map => { - const unique = new Map(); - for (const message of values) { + const unique = createPrivateMap(); + for (let messageIndex = 0; messageIndex < values.length; messageIndex++) { + if (!hasOwn(values, messageIndex)) continue; + const message = values[messageIndex]!; unique.set(message.id, unique.has(message.id) ? undefined : message); } return unique; @@ -754,7 +779,9 @@ function extractMergedRunTexts( ? createMessageOccurrenceMatcher(previousMessages, messages) : undefined; const runTexts = createPrivateSet(); - for (const run of extractMergedRuns(messages)) { + const mergedRuns = extractMergedRuns(messages); + for (let runIndex = 0; runIndex < mergedRuns.length; runIndex++) { + const run = mergedRuns[runIndex]!; // Only an identical grouping keeps its provenance. Comparing text alone // would also exempt a newly joined boundary that happens to duplicate a // different historical run, or a run shortened by trimming. @@ -806,10 +833,10 @@ function extractInputValidationTexts(input: AgentContext["input"]): InputValidat if (typeof input === "string") return { texts: [input], assembled: [] }; return { texts: flatMapPrivateArray(input, extractMessageInputText), - assembled: [ - ...flatMapPrivateArray(input, extractMessageAssembledTexts), - ...extractMergedRunTexts(input), - ], + assembled: concatPrivateArrays( + flatMapPrivateArray(input, extractMessageAssembledTexts), + extractMergedRunTexts(input), + ), }; } @@ -840,7 +867,9 @@ function sanitizeTextToFixpoint(validator: InputValidator, text: string): string function collapseTextParts(parts: Message["parts"], text: string | undefined): Message["parts"] { const collapsed: Message["parts"] = []; let replaced = false; - for (const part of parts) { + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + if (!hasOwn(parts, partIndex)) continue; + const part = parts[partIndex]!; if (!isTextPart(part)) { pushPrivateArray(collapsed, part); } else if (!replaced && text !== undefined) { @@ -941,9 +970,11 @@ function sanitizeStructuredInput(validator: InputValidator, messages: Message[]) * occurs for caller text that already required a security rewrite. */ function sanitizeMergedRuns(validator: InputValidator, messages: Message[]): Message[] { - const rewrites = new Map(); + const rewrites = createPrivateMap(); - for (const run of extractMergedRuns(messages)) { + const mergedRuns = extractMergedRuns(messages); + for (let runIndex = 0; runIndex < mergedRuns.length; runIndex++) { + const run = mergedRuns[runIndex]!; // The run-level join is "\n\n" on OpenAI-compatible providers and the // Anthropic builder, but the Google builder ships each system message as // a separate `systemInstruction` part whose server-side concatenation @@ -971,12 +1002,14 @@ function sanitizeMergedRuns(validator: InputValidator, messages: Message[]): Mes // Keep the triggering assembly: introducing a newline before sanitizing // can hide a script body from a pattern whose dot does not span lines. const collapsedText = sanitizeTextToFixpoint(validator, unsafeAssembly); - run.forEach((message, index) => { + for (let index = 0; index < run.length; index++) { + if (!hasOwn(run, index)) continue; + const message = run[index]!; rewrites.set( message, collapseTextParts(message.parts, index === 0 ? collapsedText : undefined), ); - }); + } } if (rewrites.size === 0) return messages; @@ -994,9 +1027,9 @@ async function validateInputTexts( values: InputValidationTexts, options?: InputValidationOptions, ): Promise<{ valid: boolean; violations: SecurityViolation[] }> { - const results = await Promise.all([ - ...mapPrivateArray(values.texts, (value) => validator.validate(value, options)), - ...mapPrivateArray( + const results = await Promise.all(concatPrivateArrays( + mapPrivateArray(values.texts, (value) => validator.validate(value, options)), + mapPrivateArray( values.assembled, (value) => validator.validate(value, { @@ -1005,7 +1038,7 @@ async function validateInputTexts( checkCustomValidation: false, }), ), - ]); + )); return { valid: everyPrivateArray(results, (result) => result.valid), violations: flatMapPrivateArray(results, (result) => result.violations), @@ -1365,9 +1398,13 @@ async function assertProviderRunsValid( if (providerRuns.length === 0) return; const patternOnly = { checkCustomValidation: false } as const; const introducedViolations: SecurityViolation[] = []; - for (const { text, trustedSegments } of providerRuns) { + for (let runIndex = 0; runIndex < providerRuns.length; runIndex++) { + if (!hasOwn(providerRuns, runIndex)) continue; + const { text, trustedSegments } = providerRuns[runIndex]!; const validation = await validator.validate(text, { ...patternOnly, checkMaxLength: false }); - for (const violation of validation.violations) { + for (let violationIndex = 0; violationIndex < validation.violations.length; violationIndex++) { + if (!hasOwn(validation.violations, violationIndex)) continue; + const violation = validation.violations[violationIndex]!; const pattern = violation.pattern; if (pattern === undefined) { pushPrivateArray(introducedViolations, violation); @@ -1408,15 +1445,19 @@ async function assertProviderRunsValid( ); } - for (const { text, trustedSegments } of providerRuns) { + for (let runIndex = 0; runIndex < providerRuns.length; runIndex++) { + if (!hasOwn(providerRuns, runIndex)) continue; + const { text, trustedSegments } = providerRuns[runIndex]!; let expected = ""; let cursor = 0; - for (const segment of trustedSegments) { - expected += text.slice(cursor, segment.start) + + for (let segmentIndex = 0; segmentIndex < trustedSegments.length; segmentIndex++) { + if (!hasOwn(trustedSegments, segmentIndex)) continue; + const segment = trustedSegments[segmentIndex]!; + expected += privateTextSlice(text, cursor, segment.start) + (validator.sanitize(segment.text) ?? segment.text); cursor = segment.start + segment.text.length; } - expected += text.slice(cursor); + expected += privateTextSlice(text, cursor); if ((validator.sanitize(text) ?? text) === expected) continue; const violation: SecurityViolation = { type: "input", @@ -1475,7 +1516,9 @@ function assertTextsNeedNoSanitization( reason: string, onViolation?: (violation: SecurityViolation) => void, ): void { - for (const value of values) { + for (let valueIndex = 0; valueIndex < values.length; valueIndex++) { + if (!hasOwn(values, valueIndex)) continue; + const value = values[valueIndex]!; if ((validator.sanitize(value) ?? value) === value) continue; const violation: SecurityViolation = { @@ -1624,13 +1667,13 @@ export function securityMiddleware( ), extractMessageInputText, ), - assembled: [ - ...flatMapPrivateArray( + assembled: concatPrivateArrays( + flatMapPrivateArray( filterPrivateArray(turnInput, isSummaryMemoryProjectionMessage), extractMessageInputText, ), - ...flatMapPrivateArray(turnInput, extractMessageAssembledTexts), - ], + flatMapPrivateArray(turnInput, extractMessageAssembledTexts), + ), } : { texts: [], assembled: [] }; const runTexts = extractMergedRunTexts( @@ -1643,13 +1686,16 @@ export function securityMiddleware( inputValidator, { texts: individualValues.texts, - assembled: [...individualValues.assembled, ...runTexts], + assembled: concatPrivateArrays(individualValues.assembled, runTexts), }, onProviderViolation, ); assertTextsNeedNoSanitization( inputValidator, - [...individualValues.texts, ...individualValues.assembled, ...runTexts], + concatPrivateArrays( + concatPrivateArrays(individualValues.texts, individualValues.assembled), + runTexts, + ), "Provider-visible messages contain content sanitization removes", onProviderViolation, ); @@ -1670,13 +1716,13 @@ export function securityMiddleware( filterPrivateArray(changed, (message) => !isSummaryMemoryProjectionMessage(message)), extractMessageInputText, ); - const assembled = [ - ...flatMapPrivateArray( + const assembled = concatPrivateArrays( + flatMapPrivateArray( filterPrivateArray(changed, isSummaryMemoryProjectionMessage), extractMessageInputText, ), - ...flatMapPrivateArray(changed, extractMessageAssembledTexts), - ]; + flatMapPrivateArray(changed, extractMessageAssembledTexts), + ); const runTexts = extractMergedRunTexts( messages, undefined, @@ -1685,12 +1731,12 @@ export function securityMiddleware( ); await assertInputTextsValid( inputValidator, - { texts, assembled: [...assembled, ...runTexts] }, + { texts, assembled: concatPrivateArrays(assembled, runTexts) }, onProviderViolation, ); assertTextsNeedNoSanitization( inputValidator, - [...texts, ...assembled, ...runTexts], + concatPrivateArrays(concatPrivateArrays(texts, assembled), runTexts), "Provider-visible messages contain content sanitization removes", onProviderViolation, ); @@ -1709,10 +1755,11 @@ export function securityMiddleware( filterPrivateArray(context.input, (message) => message.role === "user"), ( message, - ) => [ - buildAttachmentContextFromParts(message.parts), - ...getProviderAttachmentMetadata(message.parts), - ], + ) => + concatPrivateArrays( + [buildAttachmentContextFromParts(message.parts)], + getProviderAttachmentMetadata(message.parts), + ), ), "Attachment annotations contain content sanitization removes", config.onViolation, @@ -1772,14 +1819,14 @@ export function securityMiddleware( ), }; const completeResolvedTexts: InputValidationTexts = { - texts: [...resolvedTexts.texts, ...rewrittenRoleTexts.texts], - assembled: [...resolvedTexts.assembled, ...rewrittenRoleTexts.assembled], + texts: concatPrivateArrays(resolvedTexts.texts, rewrittenRoleTexts.texts), + assembled: concatPrivateArrays(resolvedTexts.assembled, rewrittenRoleTexts.assembled), }; if (sameTexts(completeResolvedTexts, approvedInputTexts)) return; await assertInputTextsValid(inputValidator, completeResolvedTexts, config.onViolation); assertTextsNeedNoSanitization( inputValidator, - [...completeResolvedTexts.texts, ...completeResolvedTexts.assembled], + concatPrivateArrays(completeResolvedTexts.texts, completeResolvedTexts.assembled), "Middleware-rewritten input contains content sanitization removes", config.onViolation, ); diff --git a/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts b/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts new file mode 100644 index 0000000000..507d6e555a --- /dev/null +++ b/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts @@ -0,0 +1,77 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; +import { + getTurnInputValidator, + getTurnMessageValidator, + getTurnProviderRequestValidator, +} from "#veryfront/agent/middleware/turn-validation.ts"; +import type { AgentContext, Message } from "#veryfront/agent/types.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "JSON", "iterator"]) { + describe(`private validation text aggregation ${probe}`, () => { + it("validates structured input and resolved turn text without shared serialization or iteration", async () => { + const marker = "synthetic-private-validation-aggregation"; + const input: Message[] = [ + { id: "system", role: "system", parts: [{ type: "text", text: marker }] }, + { + id: "user", + role: "user", + parts: [ + { type: "text", text: marker }, + { type: "text", text: "follow-up" }, + { type: "tool-call", toolCallId: "call", toolName: "inspect", args: { text: marker } }, + ], + }, + { id: "user2", role: "user", parts: [{ type: "text", text: "continuation" }] }, + ]; + const context: AgentContext = { + agentId: "synthetic", + input, + model: "hosted/synthetic", + data: {}, + platform: {}, + }; + const stringify = JSON.stringify; + const iterator = Array.prototype[Symbol.iterator]; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + const observe = (value: unknown) => { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + }; + let completed = false; + try { + if (probe === "JSON") { + JSON.stringify = ((...args: unknown[]) => { + observe(args[0]); + return apply(stringify, JSON, args); + }) as typeof stringify; + } + if (probe === "iterator") { + Array.prototype[Symbol.iterator] = function () { + observe(this); + return apply(iterator, this, []); + }; + } + await securityMiddleware({ input: {} })( + context, + () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), + ); + await getTurnInputValidator(context)!(input); + await getTurnMessageValidator(context)!([], input); + await getTurnProviderRequestValidator(context)!([ + { role: "system", content: marker }, + { role: "system", content: "additional instructions" }, + ], input); + completed = true; + } finally { + if (probe === "JSON") JSON.stringify = stringify; + if (probe === "iterator") Array.prototype[Symbol.iterator] = iterator; + } + assertEquals(completed, true); + assertEquals(observations, 0); + }); + }); +} From 24c68d06b2219bd43a1f7a7fb3ec4e3ea5c388b9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 20:43:19 +0200 Subject: [PATCH 159/194] fix(agent): index merged provider validation runs --- src/agent/middleware/security/validator.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 8f58789821..c632d23130 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1612,9 +1612,11 @@ export function securityMiddleware( const trusted = createPrivateSet(systemMessages); const callers = createPrivateSet(callerSystemMessages); const providerRuns: ProviderValidationRun[] = []; - for ( - const run of extractMergedSystemRuns(concatPrivateArrays(systemMessages, callerMessages)) - ) { + const mergedSystemRuns = extractMergedSystemRuns( + concatPrivateArrays(systemMessages, callerMessages), + ); + for (let runIndex = 0; runIndex < mergedSystemRuns.length; runIndex++) { + const run = mergedSystemRuns[runIndex]!; if ( !somePrivateArray(run, (message) => trusted.has(message)) || !somePrivateArray(run, (message) => callers.has(message)) From c626b91fec3d42bb22475c47014b8c024ce82487 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 20:53:04 +0200 Subject: [PATCH 160/194] fix(agent): preserve unreadable array validation errors --- src/agent/middleware/security/validator.ts | 8 +++++--- src/security/private-json.test.ts | 12 ++++++++++++ src/security/private-json.ts | 18 ++++++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index c632d23130..7f1ac79ced 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -4,7 +4,7 @@ import { privateTextTrimStart, } from "#veryfront/security/private-text.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; -import { privateJsonStringify } from "#veryfront/security/private-json.ts"; +import { PrivateJsonArrayError, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { @@ -457,10 +457,12 @@ function extractPartInputText(part: unknown): string[] { try { const serialized = privateJsonStringify(value); if (typeof serialized === "string") pushPrivateArray(values, serialized); - } catch { + } catch (error) { throw toError(createError({ type: "agent", - message: "Input validation failed: Structured input cannot be safely inspected", + message: error instanceof PrivateJsonArrayError + ? "Input validation failed: Array input cannot be safely copied" + : "Input validation failed: Structured input cannot be safely inspected", })); } }; diff --git a/src/security/private-json.test.ts b/src/security/private-json.test.ts index fd499e01d3..2b4218dc0f 100644 --- a/src/security/private-json.test.ts +++ b/src/security/private-json.test.ts @@ -3,6 +3,18 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { privateJsonStringify } from "./private-json.ts"; describe("private JSON serialization", () => { + it("reports unreadable arrays without exposing the underlying access error", () => { + const array = new Proxy(["synthetic private value"], { + get() { + throw new Error("synthetic private detail"); + }, + }); + assertThrows( + () => privateJsonStringify({ array }), + TypeError, + "Array input cannot be safely copied", + ); + }); it("preserves native scalar data without calling their serialization methods", () => { const value = { text: "å🙂", diff --git a/src/security/private-json.ts b/src/security/private-json.ts index 588bf2cc60..6e8d895c10 100644 --- a/src/security/private-json.ts +++ b/src/security/private-json.ts @@ -25,6 +25,13 @@ const bigintValue = BigInt.prototype.valueOf; const finite = Number.isFinite; const notScalar = Symbol("not-native-json-scalar"); +/** A private array could not be copied for serialization. */ +export class PrivateJsonArrayError extends NativeTypeError { + constructor() { + super("Array input cannot be safely copied"); + } +} + function nativeScalar(value: unknown): unknown { try { const time = apply(dateTime, value, []) as number; @@ -72,8 +79,12 @@ export function privateJsonStringify( const scalar = nativeScalar(input); if (scalar !== notScalar) return copy(scalar, depth + 1); } - if (array && input.length > 100_000) { - throw new NativeTypeError("Private JSON data exceeds its structural limit"); + if (array) { + try { + if (input.length > 100_000) throw new PrivateJsonArrayError(); + } catch { + throw new PrivateJsonArrayError(); + } } if (apply(setHas, ancestors, [input])) { throw new NativeTypeError("Cannot serialize circular data"); @@ -101,6 +112,9 @@ export function privateJsonStringify( defineProperty(output, key, copiedProperty); } return output; + } catch (error) { + if (array) throw new PrivateJsonArrayError(); + throw error; } finally { apply(setDelete, ancestors, [input]); } From f25df635cf184c4cc776d4e61d52bc06baa9875b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 21:04:35 +0200 Subject: [PATCH 161/194] fix(chat): inspect optional attachment fields as own data --- src/chat/provider-message-content.test.ts | 31 ++++++++++++++++ src/chat/provider-message-content.ts | 18 ++++++--- ...rovider-optional-fields-intrinsics.test.ts | 37 +++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/chat/provider-message-content.test.ts b/src/chat/provider-message-content.test.ts index b9e73a57e1..5928c22a8e 100644 --- a/src/chat/provider-message-content.test.ts +++ b/src/chat/provider-message-content.test.ts @@ -3,6 +3,37 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { cleanContent } from "./provider-message-content.ts"; describe("provider message content", () => { + it("checks attachment fields without invoking inherited or own accessors", () => { + let reads = 0; + const prototype = Object.create(null, { + filename: { + get() { + reads++; + return undefined; + }, + }, + data: { + get() { + reads++; + return undefined; + }, + }, + }); + const part = Object.assign(Object.create(prototype), { + type: "image", + mediaType: "image/png", + url: "data:image/png;base64,c3ludGhldGlj", + }); + assertEquals(cleanContent([part], "user").length, 1); + Object.defineProperty(part, "filename", { + get() { + reads++; + return undefined; + }, + }); + assertEquals(cleanContent([part], "user").length, 1); + assertEquals(reads, 0); + }); it("checks private content without invoking its own some override", () => { const content = [{ type: "text", text: "synthetic private provider content" }]; let observations = 0; diff --git a/src/chat/provider-message-content.ts b/src/chat/provider-message-content.ts index db079ebf37..84e7b8f6f4 100644 --- a/src/chat/provider-message-content.ts +++ b/src/chat/provider-message-content.ts @@ -7,8 +7,14 @@ const isArray = Array.isArray; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const hasOwn = Object.hasOwn; +function ownField(record: Record, key: string): unknown { + const descriptor = getOwnPropertyDescriptor(record, key); + return descriptor && hasOwn(descriptor, "value") ? descriptor.value : undefined; +} + function hasNonEmptyStringField(record: Record, key: string): boolean { - return typeof record[key] === "string" && privateTextTrim(record[key]).length > 0; + const value = ownField(record, key); + return typeof value === "string" && privateTextTrim(value).length > 0; } function hasValidToolResultOutput(value: unknown): boolean { @@ -33,8 +39,7 @@ function isKeepableModelPart( includeReasoning: boolean, ): boolean { if (!isRecord(part) || typeof part.type !== "string") return false; - const descriptor = getOwnPropertyDescriptor(part, "providerExecuted"); - const providerExecuted = descriptor && hasOwn(descriptor, "value") ? descriptor.value : undefined; + const providerExecuted = ownField(part, "providerExecuted"); switch (part.type) { case "text": @@ -70,8 +75,11 @@ function isKeepableModelPart( return false; } - const url = typeof part.url === "string" ? part.url : ""; - if (privateTextStartsWith(url, "data:image/") && part.filename === "preview-screenshot.png") { + const url = ownField(part, "url"); + if ( + typeof url === "string" && privateTextStartsWith(url, "data:image/") && + ownField(part, "filename") === "preview-screenshot.png" + ) { return false; } return true; diff --git a/tests/integration/agent/provider-optional-fields-intrinsics.test.ts b/tests/integration/agent/provider-optional-fields-intrinsics.test.ts index 1d731b47a7..c41309f307 100644 --- a/tests/integration/agent/provider-optional-fields-intrinsics.test.ts +++ b/tests/integration/agent/provider-optional-fields-intrinsics.test.ts @@ -9,6 +9,43 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; for (const hooks of [false, true]) { describe(`private provider optional fields ${hooks ? "hooks" : "baseline"}`, () => { + it("keeps attachment data out of inherited filename and data getters", () => { + const marker = "synthetic-private-attachment-fields"; + const part = { + type: "image", + mediaType: "image/png", + url: `data:image/png;base64,${marker}`, + }; + const getDescriptor = Object.getOwnPropertyDescriptor; + const defineProperty = Object.defineProperty; + const filename = getDescriptor(Object.prototype, "filename"); + const data = getDescriptor(Object.prototype, "data"); + let observations = 0; + let cleaned; + try { + if (hooks) { + for (const key of ["filename", "data"]) { + defineProperty(Object.prototype, key, { + configurable: true, + get() { + if (getDescriptor(this, "url")?.value === part.url) observations++; + return undefined; + }, + }); + } + } + cleaned = cleanContent([part], "user"); + } finally { + if (hooks) { + if (filename) defineProperty(Object.prototype, "filename", filename); + else Reflect.deleteProperty(Object.prototype, "filename"); + if (data) defineProperty(Object.prototype, "data", data); + else Reflect.deleteProperty(Object.prototype, "data"); + } + } + assertEquals(cleaned, [part]); + assertEquals(observations, 0); + }); it("replays local tool arguments without consulting an inherited provider flag", () => { const marker = "synthetic-private-provider-flag"; const messages: Message[] = [{ From fd6637cf0204f1ecd5c052fd79841dd7350dd1d9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 21:13:39 +0200 Subject: [PATCH 162/194] fix(agent): capture managed model serialization operations --- src/agent/hosted/executor-model-schema.ts | 35 +++++++---- .../executor-model-json-intrinsics.test.ts | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 tests/integration/agent/executor-model-json-intrinsics.test.ts diff --git a/src/agent/hosted/executor-model-schema.ts b/src/agent/hosted/executor-model-schema.ts index bc3c2b6dea..10bddbaa06 100644 --- a/src/agent/hosted/executor-model-schema.ts +++ b/src/agent/hosted/executor-model-schema.ts @@ -2,6 +2,15 @@ import type { InferSchema, Schema } 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 { getExecutorModelFailureSchema } from "./executor-model-errors.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + +const isArray = Array.isArray; +const getPrototypeOf = Object.getPrototypeOf; +const objectPrototype = Object.prototype; +const ownKeys = Reflect.ownKeys; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; const MAX_MODELS = 128; const MAX_ITEMS = 1000; @@ -274,40 +283,42 @@ export function executorModelIds(ids: ReadonlySet): Set { */ export function executorModelJson(value: unknown): JsonValue { let nodes = 0; - const ancestors = new Set(); + const ancestors = createPrivateSet(); function copy(input: unknown, depth: number): unknown { if (++nodes > 100_000 || depth > 128) throw new TypeError("Invalid managed model data"); if (input === null || typeof input !== "object") return input; if (ancestors.has(input)) throw new TypeError("Invalid managed model data"); - const array = Array.isArray(input); + const array = isArray(input); if ( - !array && Object.getPrototypeOf(input) !== Object.prototype && - Object.getPrototypeOf(input) !== null + !array && getPrototypeOf(input) !== objectPrototype && + getPrototypeOf(input) !== null ) throw new TypeError("Invalid managed model data"); ancestors.add(input); const output: Record | unknown[] = array ? [] : {}; - const keys = Reflect.ownKeys(input); + const keys = ownKeys(input); if (keys.length > 100_000) throw new TypeError("Invalid managed model data"); // First-party provider snapshots pin an inert own toJSON value on arrays. // Copy only their indexed data; never invoke or transport a serialization hook. - const guard = array ? Object.getOwnPropertyDescriptor(input, "toJSON") : undefined; - const guardedArray = guard !== undefined && "value" in guard && guard.value === undefined && + const guard = array ? getOwnPropertyDescriptor(input, "toJSON") : undefined; + const guardedArray = guard !== undefined && hasOwn(guard, "value") && + guard.value === undefined && guard.enumerable === false && guard.configurable === false && guard.writable === false; if ( array && (keys.length !== input.length + 1 + (guardedArray ? 1 : 0) || input.length > 100_000) ) { throw new TypeError("Invalid managed model data"); } - for (const key of keys) { + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; if (array && key === "length") continue; if (guardedArray && key === "toJSON") continue; - const descriptor = Object.getOwnPropertyDescriptor(input, key); + const descriptor = getOwnPropertyDescriptor(input, key); if ( - typeof key !== "string" || !descriptor || !("value" in descriptor) || !descriptor.enumerable + typeof key !== "string" || !descriptor || !hasOwn(descriptor, "value") || + !descriptor.enumerable ) throw new TypeError("Invalid managed model data"); if (!array && descriptor.value === undefined) continue; - Object.defineProperty(output, key, { - value: copy(descriptor.value, depth + 1), + defineOwnDataProperty(output, key, copy(descriptor.value, depth + 1), { enumerable: true, writable: true, configurable: true, diff --git a/tests/integration/agent/executor-model-json-intrinsics.test.ts b/tests/integration/agent/executor-model-json-intrinsics.test.ts new file mode 100644 index 0000000000..cca0962b06 --- /dev/null +++ b/tests/integration/agent/executor-model-json-intrinsics.test.ts @@ -0,0 +1,58 @@ +import { executorModelJson } from "#veryfront/agent/hosted/executor-model-schema.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "reflection", "collections"]) { + describe(`private managed model JSON ${probe}`, () => { + it("copies complete model options without exposing them through shared operations", () => { + const marker = "synthetic-private-managed-options"; + const input = { + prompt: [{ role: "user", content: [{ type: "text", text: marker }] }], + optional: undefined, + }; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const getDescriptor = Object.getOwnPropertyDescriptor; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; + let observations = 0; + const replace = (target: object, key: PropertyKey, argument: boolean) => { + const descriptor = getDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if ( + apply(includes, stringify(argument ? args[0] : this) ?? "", [marker]) + ) observations++; + return apply(descriptor.value, this, args); + }, + }); + }; + let output; + try { + if (probe === "reflection") { + replace(Reflect, "ownKeys", true); + replace(Object, "getOwnPropertyDescriptor", true); + replace(Object, "getPrototypeOf", true); + replace(Object, "defineProperty", true); + } + if (probe === "collections") { + replace(Array, "isArray", true); + replace(Set.prototype, "has", true); + replace(Set.prototype, "add", true); + replace(Set.prototype, "delete", true); + } + output = executorModelJson(input); + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(output, { prompt: input.prompt }); + assertEquals(observations, 0); + }); + }); +} From 083cd5a9ffb9a561acec091fa2d0fc8da7f358d4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 21:26:08 +0200 Subject: [PATCH 163/194] fix(agent): remove matched validation messages with private copies --- src/agent/middleware/security/validator.ts | 22 +++++++++++++------ ...dation-text-aggregation-intrinsics.test.ts | 12 +++++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 7f1ac79ced..f9f996b999 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1582,7 +1582,7 @@ export function securityMiddleware( // would brick the conversation on every later turn (`extractMergedRunTexts`). registerTurnProviderRequestValidator(context, async (providerSystem, messages) => { const systemMessages = providerSystemMessages(providerSystem); - const pendingCurrent = typeof context.input === "string" + let pendingCurrent = typeof context.input === "string" ? [] : filterPrivateArray(context.input, (message) => message.role === "system"); // Separate occurrences even when a memory adapter returns the same @@ -1604,7 +1604,7 @@ export function securityMiddleware( input === message || input.id === message.id && isDeepStrictEqual(input, message), ); if (current < 0) continue; - pendingCurrent.splice(current, 1); + pendingCurrent = filterPrivateArray(pendingCurrent, (_, index) => index !== current); currentSystemMessages.add(callerMessages[index]!); } const callerSystemMessages = filterPrivateArray( @@ -1707,13 +1707,21 @@ export function securityMiddleware( registerTurnMessageProjectionValidator(context, async (messages, previousMessages) => { // Consume occurrences, rather than IDs, so duplicate IDs and freshly // deserialized messages retain their individual validation provenance. - const remainingPrevious = previousMessages?.slice() ?? []; + let remainingPrevious = previousMessages + ? mapPrivateArray(previousMessages, (message) => message) + : []; const changed = filterPrivateArray(messages, (message) => { - const index = remainingPrevious.findIndex((previous) => - previous.id === message.id && sameProviderMessageContent(previous, message) + let index = 0; + while (index < remainingPrevious.length) { + const previous = remainingPrevious[index]!; + if (previous.id === message.id && sameProviderMessageContent(previous, message)) break; + index++; + } + if (index === remainingPrevious.length) return true; + remainingPrevious = filterPrivateArray( + remainingPrevious, + (_, candidate) => candidate !== index, ); - if (index < 0) return true; - remainingPrevious.splice(index, 1); return false; }); const texts = flatMapPrivateArray( diff --git a/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts b/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts index 507d6e555a..7017d20e61 100644 --- a/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts +++ b/tests/integration/agent/validation-text-aggregation-intrinsics.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; import { getTurnInputValidator, + getTurnMessageProjectionValidator, getTurnMessageValidator, getTurnProviderRequestValidator, } from "#veryfront/agent/middleware/turn-validation.ts"; @@ -9,7 +10,7 @@ import type { AgentContext, Message } from "#veryfront/agent/types.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -for (const probe of ["baseline", "JSON", "iterator"]) { +for (const probe of ["baseline", "JSON", "iterator", "splice"]) { describe(`private validation text aggregation ${probe}`, () => { it("validates structured input and resolved turn text without shared serialization or iteration", async () => { const marker = "synthetic-private-validation-aggregation"; @@ -35,6 +36,7 @@ for (const probe of ["baseline", "JSON", "iterator"]) { }; const stringify = JSON.stringify; const iterator = Array.prototype[Symbol.iterator]; + const splice = Array.prototype.splice; const includes = String.prototype.includes; const apply = Reflect.apply; let observations = 0; @@ -55,12 +57,19 @@ for (const probe of ["baseline", "JSON", "iterator"]) { return apply(iterator, this, []); }; } + if (probe === "splice") { + Array.prototype.splice = function (...args: unknown[]) { + observe(this); + return apply(splice, this, args); + }; + } await securityMiddleware({ input: {} })( context, () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), ); await getTurnInputValidator(context)!(input); await getTurnMessageValidator(context)!([], input); + await getTurnMessageProjectionValidator(context)!(input, input); await getTurnProviderRequestValidator(context)!([ { role: "system", content: marker }, { role: "system", content: "additional instructions" }, @@ -69,6 +78,7 @@ for (const probe of ["baseline", "JSON", "iterator"]) { } finally { if (probe === "JSON") JSON.stringify = stringify; if (probe === "iterator") Array.prototype[Symbol.iterator] = iterator; + if (probe === "splice") Array.prototype.splice = splice; } assertEquals(completed, true); assertEquals(observations, 0); From f7c01f32227a5d759083ce7ae26034c16ddf95e5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 21:35:47 +0200 Subject: [PATCH 164/194] fix(agent): protect validation promises and trusted text endings --- src/agent/middleware/security/validator.ts | 11 ++- src/security/private-promise.test.ts | 40 +++++++- src/security/private-promise.ts | 28 ++++++ ...lidation-promise-string-intrinsics.test.ts | 92 +++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/integration/agent/validation-promise-string-intrinsics.test.ts diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index f9f996b999..31b6b68bf7 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -6,6 +6,7 @@ import { import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; import { PrivateJsonArrayError, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { allPrivatePromises } from "#veryfront/security/private-promise.ts"; import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { appendPrivateArray, @@ -1029,7 +1030,7 @@ async function validateInputTexts( values: InputValidationTexts, options?: InputValidationOptions, ): Promise<{ valid: boolean; violations: SecurityViolation[] }> { - const results = await Promise.all(concatPrivateArrays( + const results = await allPrivatePromises(concatPrivateArrays( mapPrivateArray(values.texts, (value) => validator.validate(value, options)), mapPrivateArray( values.assembled, @@ -1167,6 +1168,10 @@ function assertionInspectionWidth(source: string, unicodeSets: boolean): number return width; } +function lastTextCodeUnit(text: string): string | undefined { + return text.length > 0 ? text[text.length - 1] : undefined; +} + /** Prove an assembly match also has a path using unchanged trusted context. */ function createTrustedMatchPredicate( pattern: RegExp, @@ -1221,7 +1226,7 @@ function createTrustedMatchPredicate( if (word(segment.text[0]) === (escaped === "b")) { pushPrivateArray(choices, atPosition(lower)); } - if (word(segment.text.at(-1)) === (escaped === "b")) { + if (word(lastTextCodeUnit(segment.text)) === (escaped === "b")) { pushPrivateArray(choices, atPosition(upper)); } result += "(?:" + joinPrivateArray(choices, "|") + ")"; @@ -1284,7 +1289,7 @@ function createTrustedMatchPredicate( // Only assertions at a segment edge can see different word context. const boundary = escaped === "b"; if (isWord(segment.text[0]) !== boundary) source += excludePosition(lower); - if (isWord(segment.text.at(-1)) !== boundary) source += excludePosition(upper); + if (isWord(lastTextCodeUnit(segment.text)) !== boundary) source += excludePosition(upper); } continue; } diff --git a/src/security/private-promise.test.ts b/src/security/private-promise.test.ts index c73b6ff78e..d03b65e6a7 100644 --- a/src/security/private-promise.test.ts +++ b/src/security/private-promise.test.ts @@ -1,8 +1,46 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { chainPrivatePromise, resolvePrivatePromise } from "./private-promise.ts"; +import { + allPrivatePromises, + chainPrivatePromise, + resolvePrivatePromise, +} from "./private-promise.ts"; describe("owned promise chains", () => { + it("joins out-of-order work in input order without caller iteration or then hooks", async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const inputs = [first.promise, second.promise]; + let hooks = 0; + Object.defineProperty(inputs, Symbol.iterator, { + get() { + hooks++; + return Array.prototype[Symbol.iterator]; + }, + }); + Object.defineProperty(first.promise, "then", { + get() { + hooks++; + return Promise.prototype.then; + }, + }); + const joined = allPrivatePromises(inputs); + second.resolve(2); + first.resolve(1); + assertEquals(await joined, [1, 2]); + assertEquals(hooks, 0); + assertEquals(await allPrivatePromises([]), []); + }); + + it("rejects the aggregate and still observes a later input rejection", async () => { + const first = Promise.withResolvers(); + const later = Promise.withResolvers(); + const joined = allPrivatePromises([first.promise, later.promise]); + first.reject(new Error("first failure")); + await assertRejects(() => joined, Error, "first failure"); + later.reject(new Error("late failure")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); it("propagates input and callback failures while allowing explicit recovery", async () => { const failure = new Error("Synthetic lifecycle failure"); await assertRejects( diff --git a/src/security/private-promise.ts b/src/security/private-promise.ts index d6c0ca4f82..98531a19f2 100644 --- a/src/security/private-promise.ts +++ b/src/security/private-promise.ts @@ -1,3 +1,5 @@ +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; + const NativePromise = Promise; const NativePromisePrototype = Promise.prototype; const apply = Reflect.apply; @@ -126,3 +128,29 @@ export function resolvePrivatePromise(): Promise { export function createPrivateDeferred(): PromiseWithResolvers { return apply(promiseWithResolvers, PrivatePromise, []) as PromiseWithResolvers; } + +/** Join owned promises without exposing inputs or results to shared aggregation hooks. */ +export function allPrivatePromises(promises: readonly Promise[]): Promise { + const completion = createPrivateDeferred(); + const values: T[] = []; + const length = promises.length; + let remaining = length; + values.length = remaining; + const finish = (index: number, value: unknown): void => { + defineOwnDataProperty(values, index, value, { + enumerable: true, + configurable: true, + writable: true, + }); + if (--remaining === 0) completion.resolve(values); + }; + if (remaining === 0) completion.resolve(values); + for (let index = 0; index < length; index++) { + if (!hasOwn(promises, index)) { + finish(index, undefined); + continue; + } + void chainPrivatePromise(promises[index]!, (value) => finish(index, value), completion.reject); + } + return completion.promise; +} diff --git a/tests/integration/agent/validation-promise-string-intrinsics.test.ts b/tests/integration/agent/validation-promise-string-intrinsics.test.ts new file mode 100644 index 0000000000..575bb3d43a --- /dev/null +++ b/tests/integration/agent/validation-promise-string-intrinsics.test.ts @@ -0,0 +1,92 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; +import { getTurnProviderRequestValidator } from "#veryfront/agent/middleware/turn-validation.ts"; +import type { AgentContext, Message } from "#veryfront/agent/types.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private validation promises and strings ${hooks ? "hooks" : "baseline"}`, () => { + it("rejects blocked input without exposing resolved violations through Promise.all", async () => { + const marker = "synthetic-private-validation-violation"; + const context: AgentContext = { + agentId: "synthetic", + input: marker, + model: "hosted/synthetic", + data: {}, + platform: {}, + }; + const NativePromise = Promise; + const all = Promise.all; + const then = Promise.prototype.then; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let rejected = false; + try { + if (hooks) { + Promise.all = ((values: Iterable>) => { + for (const value of values) { + apply(then, value, [(result: unknown) => { + if (apply(includes, stringify(result) ?? "", [marker])) observations++; + }, () => undefined]); + } + return apply(all, NativePromise, [values]); + }) as typeof all; + } + try { + await securityMiddleware({ input: { blockedPatterns: [/synthetic-private/] } })( + context, + () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), + ); + } catch { + rejected = true; + } + } finally { + if (hooks) Promise.all = all; + } + assertEquals(rejected, true); + assertEquals(observations, 0); + }); + + it("retains trusted word-boundary matches without exposing the segment to String.at", async () => { + const marker = "synthetic-private-trusted"; + const input: Message[] = [{ + id: "caller", + role: "system", + parts: [{ type: "text", text: "allowed" }], + }]; + const context: AgentContext = { + agentId: "synthetic", + input, + model: "hosted/synthetic", + data: {}, + platform: {}, + }; + await securityMiddleware({ input: { blockedPatterns: [/\bsynthetic-private-trusted\b/] } })( + context, + () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), + ); + const at = String.prototype.at; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let validated = false; + try { + if (hooks) { + String.prototype.at = function (...args) { + if (apply(includes, this, [marker])) observations++; + return apply(at, this, args); + }; + } + await getTurnProviderRequestValidator(context)!(marker, input); + validated = true; + } finally { + if (hooks) String.prototype.at = at; + } + assertEquals(validated, true); + assertEquals(observations, 0); + }); + }); +} From 495bf3466804b47f8bd59a05dd5a506b978d52b6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 21:49:46 +0200 Subject: [PATCH 165/194] fix(agent): protect trusted provider text inspection --- src/agent/middleware/security/validator.ts | 71 ++++++++++++------- src/security/private-text.ts | 10 +++ ...lidation-promise-string-intrinsics.test.ts | 26 ++++++- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/src/agent/middleware/security/validator.ts b/src/agent/middleware/security/validator.ts index 31b6b68bf7..ec09bf9889 100644 --- a/src/agent/middleware/security/validator.ts +++ b/src/agent/middleware/security/validator.ts @@ -1,4 +1,6 @@ import { + privateTextCharCodeAt, + privateTextCodePointAt, privateTextSlice, privateTextTrim, privateTextTrimStart, @@ -173,7 +175,7 @@ function freshStatefulPattern(pattern: RegExp): RegExp { function advanceStringIndex(input: string, index: number, unicode: boolean): number { if (!unicode) return index + 1; - return index + ((input.codePointAt(index) ?? 0) > 0xffff ? 2 : 1); + return index + ((privateTextCodePointAt(input, index) ?? 0) > 0xffff ? 2 : 1); } function redactBlockedPattern(input: string, pattern: RegExp): string { @@ -1127,7 +1129,7 @@ function negativeAssertionBody( groupDepth++; } else if (character === ")" && --groupDepth === 0) { return { - body: source.slice(start + prefixLength, index), + body: privateTextSlice(source, start + prefixLength, index), end: index, context, backrefs, @@ -1155,7 +1157,7 @@ function assertionInspectionWidth(source: string, unicodeSets: boolean): number else if (classDepth === 0) { if (character === "*" || character === "+") return Infinity; if (character === "{") { - const quantifier = /^\{(\d+)(?:,(\d*))?\}/.exec(source.slice(index)); + const quantifier = /^\{(\d+)(?:,(\d*))?\}/.exec(privateTextSlice(source, index)); if (quantifier) { if (quantifier[2] === "") return Infinity; width *= Math.max(1, Number(quantifier[2] ?? quantifier[1])); @@ -1180,8 +1182,10 @@ function createTrustedMatchPredicate( ): (match: { index: number; text: string }) => boolean { const unicode = pattern.unicode || pattern.unicodeSets; const splitsCodePoint = (offset: number) => - assembled.charCodeAt(offset - 1) >= 0xd800 && assembled.charCodeAt(offset - 1) <= 0xdbff && - assembled.charCodeAt(offset) >= 0xdc00 && assembled.charCodeAt(offset) <= 0xdfff; + privateTextCharCodeAt(assembled, offset - 1) >= 0xd800 && + privateTextCharCodeAt(assembled, offset - 1) <= 0xdbff && + privateTextCharCodeAt(assembled, offset) >= 0xdc00 && + privateTextCharCodeAt(assembled, offset) <= 0xdfff; // Joining lone surrogates changes Unicode matching even without assertions. if ( unicode && @@ -1189,8 +1193,15 @@ function createTrustedMatchPredicate( ) { return () => false; } - const units = (text: string) => unicode ? [...text].length : text.length; - const lower = units(assembled.slice(0, segment.start)); + const units = (text: string) => { + if (!unicode) return text.length; + let count = 0; + for (let index = 0; index < text.length; count++) { + index += (privateTextCodePointAt(text, index) ?? 0) > 0xffff ? 2 : 1; + } + return count; + }; + const lower = units(privateTextSlice(assembled, 0, segment.start)); const upper = lower + units(segment.text); const lowerGuard = "(?<=[\\s\\S]{" + lower + "})"; const upperGuard = "(? character !== undefined && - new RegExp( - "\\w", - (pattern.unicodeSets ? "v" : pattern.unicode ? "u" : "") + (ignoreCase ? "i" : ""), - ) - .test(character); + testBlockedPattern( + new RegExpConstructor( + "\\w", + (pattern.unicodeSets ? "v" : pattern.unicode ? "u" : "") + (ignoreCase ? "i" : ""), + ), + character, + ); const localNegativeBody = (body: string): string => { let result = ""; let depth = 0; @@ -1213,10 +1226,13 @@ function createTrustedMatchPredicate( const cases: Array<{ ignoreCase: boolean; assertion?: "ahead" | "behind" }> = []; const word = (character: string | undefined) => character !== undefined && - new RegExp( - "\\w", - (pattern.unicodeSets ? "v" : pattern.unicode ? "u" : "") + (localIgnoreCase ? "i" : ""), - ).test(character); + testBlockedPattern( + new RegExpConstructor( + "\\w", + (pattern.unicodeSets ? "v" : pattern.unicode ? "u" : "") + (localIgnoreCase ? "i" : ""), + ), + character, + ); for (let index = 0; index < body.length; index++) { const character = body[index]; if (character === "\\") { @@ -1241,7 +1257,7 @@ function createTrustedMatchPredicate( continue; } if (character === "(") { - const assertion = /^\(\?(]+>/.exec(body.slice(index)); + const named = /^\(\?<[^>]+>/.exec(privateTextSlice(body, index)); if (body[index + 1] !== "?" || named) { result += "(?:"; if (named) index += named[0].length - 1; continue; } - const flags = /^\(\?([ims]*)(?:-([ims]+))?:/.exec(body.slice(index)); + const flags = /^\(\?([ims]*)(?:-([ims]+))?:/.exec(privateTextSlice(body, index)); if (flags?.[1]?.includes("i")) localIgnoreCase = true; if (flags?.[2]?.includes("i")) localIgnoreCase = false; } else if (character === ")") { @@ -1310,7 +1326,7 @@ function createTrustedMatchPredicate( if (character === "(") { const negative = negativeAssertionBody(pattern, index); if (negative) { - const original = pattern.source.slice(index, negative.end + 1); + const original = privateTextSlice(pattern.source, index, negative.end + 1); if ( negative.nestedNegative || ((negative.nested || negative.context) && negative.backrefs) ) { @@ -1348,7 +1364,9 @@ function createTrustedMatchPredicate( continue; } pushPrivateArray(groups, { kind: "ordinary", ignoreCase }); - const modifiers = /^\(\?([ims]*)(?:-([ims]+))?:/.exec(pattern.source.slice(index)); + const modifiers = /^\(\?([ims]*)(?:-([ims]+))?:/.exec( + privateTextSlice(pattern.source, index), + ); if (modifiers?.[1]?.includes("i")) ignoreCase = true; if (modifiers?.[2]?.includes("i")) ignoreCase = false; } else if (character === ")") { @@ -1359,7 +1377,7 @@ function createTrustedMatchPredicate( inspectionRadius = Math.max( inspectionRadius, assertionInspectionWidth( - pattern.source.slice(group.bodyStart, index), + privateTextSlice(pattern.source, group.bodyStart, index), pattern.unicodeSets, ) * (unicode ? 2 : 1), ); @@ -1379,7 +1397,7 @@ function createTrustedMatchPredicate( // Guard positive assertions' consumed context without adding captures or // changing backreference numbers. Contextual negative assertions also // check a capture-free body against the trusted segment's own boundaries. - const matcher = new RegExp(source, pattern.flags.replace(/[gy]/g, "") + "y"); + const matcher = new RegExpConstructor(source, pattern.flags.replace(/[gy]/g, "") + "y"); return (match) => { // Interior matches cannot inspect caller text. Avoid prefix scans for // each occurrence in long trusted prompts with many boundary matches. @@ -1388,7 +1406,7 @@ function createTrustedMatchPredicate( match.index + match.text.length + inspectionRadius <= segment.text.length ) return true; matcher.lastIndex = segment.start + match.index; - const found = matcher.exec(assembled); + const found = applyRegExp(regexpExec, matcher, [assembled]) as RegExpExecArray | null; return found?.index === segment.start + match.index && found[0] === match.text; }; } catch { @@ -1479,7 +1497,10 @@ async function assertProviderRunsValid( } function patternOccurrences(input: string, pattern: RegExp): { index: number; text: string }[] { - const matcher = new RegExp(pattern.source, pattern.global ? pattern.flags : `${pattern.flags}g`); + const matcher = new RegExpConstructor( + pattern.source, + pattern.global ? pattern.flags : `${pattern.flags}g`, + ); if (pattern.sticky) matcher.lastIndex = pattern.lastIndex; const matches: { index: number; text: string }[] = []; for ( diff --git a/src/security/private-text.ts b/src/security/private-text.ts index 01b959c6ad..49dbaafc70 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -7,6 +7,8 @@ const trimStart = String.prototype.trimStart; const trim = String.prototype.trim; const trimEnd = String.prototype.trimEnd; const lastIndexOf = String.prototype.lastIndexOf; +const charCodeAt = String.prototype.charCodeAt; +const codePointAt = String.prototype.codePointAt; const toLowerCase = String.prototype.toLowerCase; export function privateTextToLowerCase(value: string): string { @@ -25,6 +27,14 @@ export function privateTextLastIndexOf(value: string, search: string): number { return apply(lastIndexOf, value, [search]) as number; } +export function privateTextCharCodeAt(value: string, index: number): number { + return apply(charCodeAt, value, [index]) as number; +} + +export function privateTextCodePointAt(value: string, index: number): number | undefined { + return apply(codePointAt, value, [index]) as number | undefined; +} + export function privateTextEndsWith(value: string, search: string): boolean { return apply(endsWith, value, [search]) as boolean; } diff --git a/tests/integration/agent/validation-promise-string-intrinsics.test.ts b/tests/integration/agent/validation-promise-string-intrinsics.test.ts index 575bb3d43a..4ad1e8a04f 100644 --- a/tests/integration/agent/validation-promise-string-intrinsics.test.ts +++ b/tests/integration/agent/validation-promise-string-intrinsics.test.ts @@ -50,7 +50,7 @@ for (const hooks of [false, true]) { assertEquals(observations, 0); }); - it("retains trusted word-boundary matches without exposing the segment to String.at", async () => { + it("retains trusted word-boundary matches without shared string inspection", async () => { const marker = "synthetic-private-trusted"; const input: Message[] = [{ id: "caller", @@ -64,11 +64,14 @@ for (const hooks of [false, true]) { data: {}, platform: {}, }; - await securityMiddleware({ input: { blockedPatterns: [/\bsynthetic-private-trusted\b/] } })( + await securityMiddleware({ input: { blockedPatterns: [/\btrusted\b/u] } })( context, () => Promise.resolve({ text: "ok", messages: [], toolCalls: [], status: "completed" }), ); const at = String.prototype.at; + const slice = String.prototype.slice; + const charCodeAt = String.prototype.charCodeAt; + const codePointAt = String.prototype.codePointAt; const includes = String.prototype.includes; const apply = Reflect.apply; let observations = 0; @@ -79,11 +82,28 @@ for (const hooks of [false, true]) { if (apply(includes, this, [marker])) observations++; return apply(at, this, args); }; + String.prototype.slice = function (...args) { + if (apply(includes, this, [marker])) observations++; + return apply(slice, this, args); + }; + String.prototype.charCodeAt = function (...args) { + if (apply(includes, this, [marker])) observations++; + return apply(charCodeAt, this, args); + }; + String.prototype.codePointAt = function (...args) { + if (apply(includes, this, [marker])) observations++; + return apply(codePointAt, this, args); + }; } await getTurnProviderRequestValidator(context)!(marker, input); validated = true; } finally { - if (hooks) String.prototype.at = at; + if (hooks) { + String.prototype.at = at; + String.prototype.slice = slice; + String.prototype.charCodeAt = charCodeAt; + String.prototype.codePointAt = codePointAt; + } } assertEquals(validated, true); assertEquals(observations, 0); From 2a26ab7b46ae913c94c85bf7d2f5cb0b608efe66 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:06:50 +0200 Subject: [PATCH 166/194] fix(agent): protect per-step prompt and skill traversal --- src/agent/runtime/agent-runtime-step.ts | 39 +++++-- src/agent/runtime/prompt-block.ts | 4 +- src/agent/runtime/repair-tool-call.ts | 8 +- src/agent/runtime/run-runtime-context.ts | 39 ++++--- src/agent/runtime/skill-policy-enforcement.ts | 5 +- src/agent/runtime/skill-policy.test.ts | 14 +++ src/security/private-text.ts | 5 + ...er-step-private-content-intrinsics.test.ts | 107 ++++++++++++++++++ 8 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 tests/integration/agent/per-step-private-content-intrinsics.test.ts diff --git a/src/agent/runtime/agent-runtime-step.ts b/src/agent/runtime/agent-runtime-step.ts index d873163ab0..3c56fa51df 100644 --- a/src/agent/runtime/agent-runtime-step.ts +++ b/src/agent/runtime/agent-runtime-step.ts @@ -1,3 +1,15 @@ +import { + concatPrivateArrays, + filterPrivateArray, + flatMapPrivateArray, + joinPrivateArray, +} from "#veryfront/security/private-array.ts"; +import { + privateTextIndexOf, + privateTextSlice, + privateTextTrimEnd, + privateTextTrimStart, +} from "#veryfront/security/private-text.ts"; import type { RemoteToolSource, ToolDefinition, ToolExecutionContext } from "#veryfront/tool"; import type { ModelRuntime } from "#veryfront/provider"; import type { AgentConfig, AgentSystem, Message } from "../types.ts"; @@ -122,20 +134,24 @@ const INTEGRATION_TOOL_DISCOVERY_STATUS_FOOTER = "End integration tool discovery function removeIntegrationToolDiscoveryStatusText(systemPrompt: string): string { let result = systemPrompt; while (true) { - const headerIndex = result.indexOf(INTEGRATION_TOOL_DISCOVERY_STATUS_HEADER); + const headerIndex = privateTextIndexOf(result, INTEGRATION_TOOL_DISCOVERY_STATUS_HEADER); if (headerIndex < 0) return result; - const footerIndex = result.indexOf( + const footerIndex = privateTextIndexOf( + result, INTEGRATION_TOOL_DISCOVERY_STATUS_FOOTER, headerIndex + INTEGRATION_TOOL_DISCOVERY_STATUS_HEADER.length, ); if (footerIndex < 0) return result; - result = [ - result.slice(0, headerIndex).trimEnd(), - result.slice( - footerIndex + INTEGRATION_TOOL_DISCOVERY_STATUS_FOOTER.length, - ).trimStart(), - ].filter(Boolean).join("\n\n"); + result = joinPrivateArray( + filterPrivateArray([ + privateTextTrimEnd(privateTextSlice(result, 0, headerIndex)), + privateTextTrimStart( + privateTextSlice(result, footerIndex + INTEGRATION_TOOL_DISCOVERY_STATUS_FOOTER.length), + ), + ], (text) => text.length > 0), + "\n\n", + ); } } @@ -144,7 +160,7 @@ function removeIntegrationToolDiscoveryStatus(systemPrompt: AgentSystem): AgentS return removeIntegrationToolDiscoveryStatusText(systemPrompt); } - return systemPrompt.flatMap((message) => { + return flatMapPrivateArray(systemPrompt, (message) => { const content = removeIntegrationToolDiscoveryStatusText(message.content); return content.length > 0 ? [{ ...message, content }] : []; }); @@ -178,7 +194,10 @@ export function withIntegrationToolDiscoveryStatus( `${INTEGRATION_TOOL_DISCOVERY_STATUS_HEADER}\n\n${message}\n\n${INTEGRATION_TOOL_DISCOVERY_STATUS_FOOTER}`; return typeof basePrompt === "string" ? basePrompt.length > 0 ? `${basePrompt}\n\n${statusBlock}` : statusBlock - : [...basePrompt, { role: "system", content: statusBlock }]; + : concatPrivateArrays(basePrompt, [{ + role: "system", + content: statusBlock, + }]); } /** diff --git a/src/agent/runtime/prompt-block.ts b/src/agent/runtime/prompt-block.ts index a99b5820b5..725771d0bd 100644 --- a/src/agent/runtime/prompt-block.ts +++ b/src/agent/runtime/prompt-block.ts @@ -1,3 +1,5 @@ +import { privateTextTrim } from "#veryfront/security/private-text.ts"; + /** Options accepted by runtime prompt block. */ export type RuntimePromptBlockOptions = { name: string; @@ -17,5 +19,5 @@ export function createRuntimePromptBlock({ .join("") : ""; - return `<${name}${attrString}>\n${content.trim()}\n`; + return `<${name}${attrString}>\n${privateTextTrim(content)}\n`; } diff --git a/src/agent/runtime/repair-tool-call.ts b/src/agent/runtime/repair-tool-call.ts index 1e5aef10e6..fb529eeff4 100644 --- a/src/agent/runtime/repair-tool-call.ts +++ b/src/agent/runtime/repair-tool-call.ts @@ -1,8 +1,10 @@ import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; +import { privateTextTrim } from "#veryfront/security/private-text.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { isInvalidToolInputError, isNoSuchToolError } from "./runtime-tool-errors.ts"; import type { RuntimeToolCallRepairFunction } from "./runtime-tool-types.ts"; -const REPAIRABLE_PROVIDER_TOOL_NAMES = new Set(["web_search"]); +const REPAIRABLE_PROVIDER_TOOL_NAMES = createPrivateSet(["web_search"]); export const repairToolCall: RuntimeToolCallRepairFunction = async ({ toolCall, @@ -24,7 +26,7 @@ export const repairToolCall: RuntimeToolCallRepairFunction = async ({ return null; } - const trimmedInput = toolCall.input.trim(); + const trimmedInput = privateTextTrim(toolCall.input); if (trimmedInput.length === 0) { return null; } @@ -34,7 +36,7 @@ export const repairToolCall: RuntimeToolCallRepairFunction = async ({ try { const parsedInput = privateJsonParse(trimmedInput) as unknown; if (typeof parsedInput === "string") { - normalizedQuery = parsedInput.trim(); + normalizedQuery = privateTextTrim(parsedInput); } } catch { // Raw string input is also repairable for provider-native web_search. diff --git a/src/agent/runtime/run-runtime-context.ts b/src/agent/runtime/run-runtime-context.ts index 719df1d804..61b3dad1be 100644 --- a/src/agent/runtime/run-runtime-context.ts +++ b/src/agent/runtime/run-runtime-context.ts @@ -1,3 +1,6 @@ +import { concatPrivateArrays, flatMapPrivateArray } from "#veryfront/security/private-array.ts"; +import { privateTextSlice, privateTextTrim } from "#veryfront/security/private-text.ts"; +import { execPrivateRegExp, replacePrivateRegExp } from "#veryfront/security/private-regexp.ts"; import { createRuntimePromptBlock } from "./prompt-block.ts"; import type { AgentSystem } from "#veryfront/agent/types.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; @@ -18,39 +21,49 @@ export function captureAgentRunRuntimeContext(now = new Date()): AgentRunRuntime const runStartedAtUtc = now.toISOString(); return Object.freeze({ currentTimeUtc: runStartedAtUtc, - currentDateUtc: runStartedAtUtc.slice(0, 10), + currentDateUtc: privateTextSlice(runStartedAtUtc, 0, 10), runStartedAtUtc, }); } function removeReservedRuntimeContextBlocks(instructions: string): string { let result = instructions; - let openIndex = result.search(RUNTIME_CONTEXT_OPEN_TAG_PATTERN); + let openIndex = execPrivateRegExp(RUNTIME_CONTEXT_OPEN_TAG_PATTERN, result)?.index ?? -1; while (openIndex >= 0) { - const openingTag = result.slice(openIndex).match(RUNTIME_CONTEXT_OPEN_TAG_PATTERN)?.[0]; + const openingTag = execPrivateRegExp( + RUNTIME_CONTEXT_OPEN_TAG_PATTERN, + privateTextSlice(result, openIndex), + )?.[0]; if (!openingTag) break; const contentStart = openIndex + openingTag.length; - const closeOffset = result.slice(contentStart).search(RUNTIME_CONTEXT_CLOSE_TAG_PATTERN); + const closeOffset = + execPrivateRegExp(RUNTIME_CONTEXT_CLOSE_TAG_PATTERN, privateTextSlice(result, contentStart)) + ?.index ?? -1; if (closeOffset < 0) { // An unclosed authored tag must not swallow everything after it: later // framework-authored blocks are appended behind authored instructions, so // truncating here would let authored content delete those guardrails. // Drop only the reserved opening tag and keep scanning the remainder. - result = result.slice(0, openIndex) + result.slice(contentStart); - openIndex = result.search(RUNTIME_CONTEXT_OPEN_TAG_PATTERN); + result = privateTextSlice(result, 0, openIndex) + privateTextSlice(result, contentStart); + openIndex = execPrivateRegExp(RUNTIME_CONTEXT_OPEN_TAG_PATTERN, result)?.index ?? -1; continue; } const closeIndex = contentStart + closeOffset; - const closingTag = result.slice(closeIndex).match(RUNTIME_CONTEXT_CLOSE_TAG_PATTERN)?.[0]; + const closingTag = execPrivateRegExp( + RUNTIME_CONTEXT_CLOSE_TAG_PATTERN, + privateTextSlice(result, closeIndex), + )?.[0]; if (!closingTag) break; - result = result.slice(0, openIndex) + - result.slice(closeIndex + closingTag.length); - openIndex = result.search(RUNTIME_CONTEXT_OPEN_TAG_PATTERN); + result = privateTextSlice(result, 0, openIndex) + + privateTextSlice(result, closeIndex + closingTag.length); + openIndex = execPrivateRegExp(RUNTIME_CONTEXT_OPEN_TAG_PATTERN, result)?.index ?? -1; } - return result.replaceAll(RUNTIME_CONTEXT_CLOSE_TAG_PATTERN_GLOBAL, "").trim(); + return privateTextTrim( + replacePrivateRegExp(RUNTIME_CONTEXT_CLOSE_TAG_PATTERN_GLOBAL, result, ""), + ); } /** Render the authoritative UTC snapshot as a reserved system block. */ @@ -90,11 +103,11 @@ export function withAgentRunRuntimeContext( return base.length > 0 ? `${base}\n\n${block}` : block; } - const base = instructions.flatMap((message) => { + const base = flatMapPrivateArray(instructions, (message) => { const content = removeReservedRuntimeContextBlocks(message.content); return content.length > 0 ? [{ ...message, content }] : []; }); - return [...base, { role: "system", content: block }]; + return concatPrivateArrays(base, [{ role: "system", content: block }]); } /** Add the exact run snapshot to response diagnostics without dropping other metadata. */ diff --git a/src/agent/runtime/skill-policy-enforcement.ts b/src/agent/runtime/skill-policy-enforcement.ts index b42b090067..f7216ce24f 100644 --- a/src/agent/runtime/skill-policy-enforcement.ts +++ b/src/agent/runtime/skill-policy-enforcement.ts @@ -148,8 +148,11 @@ export function hydrateActiveSkillStateFromMessages( }; for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!objectHasOwn(messages, messageIndex)) continue; const message = messages[messageIndex]!; - for (const part of message.parts) { + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + if (!objectHasOwn(message.parts, partIndex)) continue; + const part = message.parts[partIndex]!; if (!isToolResultPart(part) || part.toolName !== LOAD_SKILL_TOOL_ID) continue; state = applySkillActivationResult(state, part.result); } diff --git a/src/agent/runtime/skill-policy.test.ts b/src/agent/runtime/skill-policy.test.ts index 2a715f875f..0e254677da 100644 --- a/src/agent/runtime/skill-policy.test.ts +++ b/src/agent/runtime/skill-policy.test.ts @@ -18,6 +18,20 @@ import { import { markRuntimeGeneratedUserMessage } from "./runtime-message-origin.ts"; describe("src/agent/runtime skill policy helpers", () => { + it("hydrates ordinary message parts without consulting their iterator", () => { + let reads = 0; + const parts: Message["parts"] = [{ type: "text", text: "synthetic private skill history" }]; + Object.defineProperty(parts, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + const state = hydrateActiveSkillStateFromMessages([{ id: "user", role: "user", parts }]); + assertEquals(state.activeSkillId, undefined); + assertEquals(state.activeSkillToolAvailability, INACTIVE_SKILL_TOOL_AVAILABILITY); + assertEquals(reads, 0); + }); it("scans submitted forms without invoking overridden array methods", () => { const parts: Message["parts"] = [{ type: "tool-result", diff --git a/src/security/private-text.ts b/src/security/private-text.ts index 49dbaafc70..713c4d6e50 100644 --- a/src/security/private-text.ts +++ b/src/security/private-text.ts @@ -7,6 +7,7 @@ const trimStart = String.prototype.trimStart; const trim = String.prototype.trim; const trimEnd = String.prototype.trimEnd; const lastIndexOf = String.prototype.lastIndexOf; +const indexOf = String.prototype.indexOf; const charCodeAt = String.prototype.charCodeAt; const codePointAt = String.prototype.codePointAt; const toLowerCase = String.prototype.toLowerCase; @@ -27,6 +28,10 @@ export function privateTextLastIndexOf(value: string, search: string): number { return apply(lastIndexOf, value, [search]) as number; } +export function privateTextIndexOf(value: string, search: string, start?: number): number { + return apply(indexOf, value, [search, start]) as number; +} + export function privateTextCharCodeAt(value: string, index: number): number { return apply(charCodeAt, value, [index]) as number; } diff --git a/tests/integration/agent/per-step-private-content-intrinsics.test.ts b/tests/integration/agent/per-step-private-content-intrinsics.test.ts new file mode 100644 index 0000000000..d52aad4b60 --- /dev/null +++ b/tests/integration/agent/per-step-private-content-intrinsics.test.ts @@ -0,0 +1,107 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { withIntegrationToolDiscoveryStatus } from "#veryfront/agent/runtime/agent-runtime-step.ts"; +import { withAgentRunRuntimeContext } from "#veryfront/agent/runtime/run-runtime-context.ts"; +import { hydrateActiveSkillStateFromMessages } from "#veryfront/agent/runtime/skill-policy-enforcement.ts"; +import { repairToolCall } from "#veryfront/agent/runtime/repair-tool-call.ts"; +import { createInvalidToolInputErrorForTest } from "#veryfront/agent/runtime/runtime-tool-errors.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "iterator", "flatMap", "strings"]) { + describe(`private per-step content ${probe}`, () => { + it("hydrates history, replaces prompt blocks and repairs arguments without shared hooks", async () => { + const marker = "synthetic-private-per-step-content"; + const instructions = [{ + role: "system" as const, + content: + `${marker}\nIntegration tool discovery status:\nold\nEnd integration tool discovery status.\nold`, + }]; + const context = { + currentTimeUtc: "2026-09-09T00:00:00.000Z", + currentDateUtc: "2026-09-09", + runStartedAtUtc: "2026-09-09T00:00:00.000Z", + }; + const error = createInvalidToolInputErrorForTest({ + cause: new Error("Expected object"), + toolInput: marker, + toolName: "web_search", + }); + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; + let observations = 0; + const replace = (target: object, key: PropertyKey) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (apply(includes, stringify(this) ?? "", [marker])) observations++; + return apply(descriptor.value, this, args); + }, + }); + }; + let prompts; + let raw; + let quoted; + let state; + try { + if (probe === "iterator") replace(Array.prototype, Symbol.iterator); + if (probe === "flatMap") replace(Array.prototype, "flatMap"); + if (probe === "strings") { + for ( + const key of [ + "trim", + "trimStart", + "trimEnd", + "indexOf", + "slice", + "search", + "match", + "replaceAll", + ] + ) replace(String.prototype, key); + } + state = hydrateActiveSkillStateFromMessages([{ + id: "user", + role: "user", + parts: [{ type: "text", text: marker }], + }]); + prompts = withAgentRunRuntimeContext( + withIntegrationToolDiscoveryStatus(instructions, undefined), + context, + ); + const repair = (input: string) => + repairToolCall({ + error, + inputSchema: () => Promise.resolve({ type: "object" }), + messages: [], + system: undefined, + tools: {}, + toolCall: { + type: "tool-call", + toolCallId: "call", + toolName: "web_search", + providerExecuted: true, + input, + }, + }); + raw = await repair(` ${marker} `); + quoted = await repair(stringify(` ${marker} `)); + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(state?.activeSkillId, undefined); + assertEquals(prompts?.[0]?.content, marker); + assertStringIncludes(prompts?.[1]?.content ?? "", "2026-09-09"); + assertEquals(raw?.input, stringify({ query: marker })); + assertEquals(quoted?.input, stringify({ query: marker })); + assertEquals(observations, 0); + }); + }); +} From 2c8e11511a3c42321e0ab8fdadeefcf528140082 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:20:48 +0200 Subject: [PATCH 167/194] fix(agent): protect initial context and provider snapshot scans --- src/agent/runtime/call-context.ts | 176 +++++++++++------- .../runtime-provider-adapter.test.ts | 23 +++ .../lifecycle/runtime-provider-adapter.ts | 9 +- ...ext-provider-collection-intrinsics.test.ts | 93 +++++++++ 4 files changed, 235 insertions(+), 66 deletions(-) create mode 100644 tests/integration/agent/context-provider-collection-intrinsics.test.ts diff --git a/src/agent/runtime/call-context.ts b/src/agent/runtime/call-context.ts index c3d865e93a..c86eacc54c 100644 --- a/src/agent/runtime/call-context.ts +++ b/src/agent/runtime/call-context.ts @@ -1,4 +1,23 @@ -import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { + appendPrivateArray, + concatPrivateArrays, + filterPrivateArray, + flatMapPrivateArray, + joinPrivateArray, + mapPrivateArray, + pushPrivateArray, + slicePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { + privateTextIndexOf, + privateTextSlice, + privateTextStartsWith, + privateTextTrim, + privateTextTrimEnd, + privateTextTrimStart, +} from "#veryfront/security/private-text.ts"; +import { execPrivateRegExp } from "#veryfront/security/private-regexp.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; /** * Agent Call Context * @@ -45,6 +64,8 @@ import { flattenSystemInstructions } from "./tool-inventory.ts"; import { isOwnDataPropertyDescriptor, readOwnDataProperty } from "./data-property-descriptor.ts"; const ObjectDefineProperty = Object.defineProperty; +const hasOwn = Object.hasOwn; +const isArray = Array.isArray; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const ObjectGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; const ReflectApply = Reflect.apply; @@ -129,21 +150,23 @@ function splitInstructionsAtMarker(input: { instructions: string; runtimeContextMarker: string; }): { before: string; after: string | null; hasMarker: boolean } { - const markerIndex = input.instructions.indexOf(input.runtimeContextMarker); + const markerIndex = privateTextIndexOf(input.instructions, input.runtimeContextMarker); if (markerIndex < 0) { return { before: input.instructions, after: null, hasMarker: false }; } return { - before: input.instructions.slice(0, markerIndex).trim(), - after: input.instructions.slice(markerIndex + input.runtimeContextMarker.length).trim() || null, + before: privateTextTrim(privateTextSlice(input.instructions, 0, markerIndex)), + after: privateTextTrim( + privateTextSlice(input.instructions, markerIndex + input.runtimeContextMarker.length), + ) || null, hasMarker: true, }; } function getBlockName(block: string): string | null { - return /^<([A-Za-z0-9_-]+)[\s>]/.exec(block)?.[1] ?? null; + return execPrivateRegExp(/^<([A-Za-z0-9_-]+)[\s>]/, block)?.[1] ?? null; } /** @@ -153,28 +176,28 @@ function getBlockName(block: string): string | null { * closing tag follows it. */ function hasBlock(instructions: string, blockName: string): boolean { - const openIndex = instructions.indexOf(`<${blockName}>`); + const openIndex = privateTextIndexOf(instructions, `<${blockName}>`); if (openIndex < 0) { return false; } - return instructions.indexOf(``, openIndex) > openIndex; + return privateTextIndexOf(instructions, ``, openIndex) > openIndex; } function removeCompleteBlocks(instructions: string, blockName: string): string { const openTag = `<${blockName}>`; const closeTag = ``; let result = instructions; - let openIndex = result.indexOf(openTag); + let openIndex = privateTextIndexOf(result, openTag); while (openIndex >= 0) { - const closeIndex = result.indexOf(closeTag, openIndex + openTag.length); + const closeIndex = privateTextIndexOf(result, closeTag, openIndex + openTag.length); if (closeIndex < 0) { break; } - const before = result.slice(0, openIndex).trimEnd(); - const after = result.slice(closeIndex + closeTag.length).trimStart(); + const before = privateTextTrimEnd(privateTextSlice(result, 0, openIndex)); + const after = privateTextTrimStart(privateTextSlice(result, closeIndex + closeTag.length)); result = before.length > 0 && after.length > 0 ? `${before}\n\n${after}` : `${before}${after}`; - openIndex = result.indexOf(openTag); + openIndex = privateTextIndexOf(result, openTag); } return result; @@ -187,21 +210,23 @@ function removeGeneratedSkillCatalogBlocks(instructions: string): string { let searchIndex = 0; while (searchIndex < result.length) { - const openIndex = result.indexOf(openTag, searchIndex); + const openIndex = privateTextIndexOf(result, openTag, searchIndex); if (openIndex < 0) { break; } - const closeIndex = result.indexOf(closeTag, openIndex + openTag.length); + const closeIndex = privateTextIndexOf(result, closeTag, openIndex + openTag.length); if (closeIndex < 0) { break; } - const content = result.slice(openIndex + openTag.length, closeIndex).trimStart(); - if (!content.startsWith(RUNTIME_GENERATED_SKILL_CATALOG_MARKER)) { + const content = privateTextTrimStart( + privateTextSlice(result, openIndex + openTag.length, closeIndex), + ); + if (!privateTextStartsWith(content, RUNTIME_GENERATED_SKILL_CATALOG_MARKER)) { searchIndex = closeIndex + closeTag.length; continue; } - const before = result.slice(0, openIndex).trimEnd(); - const after = result.slice(closeIndex + closeTag.length).trimStart(); + const before = privateTextTrimEnd(privateTextSlice(result, 0, openIndex)); + const after = privateTextTrimStart(privateTextSlice(result, closeIndex + closeTag.length)); result = before.length > 0 && after.length > 0 ? `${before}\n\n${after}` : `${before}${after}`; searchIndex = 0; } @@ -232,7 +257,9 @@ function snapshotOwnEnumerableDataRecord( const snapshot: Record = {}; const keys = ReflectApply(ReflectOwnKeys, undefined, [descriptors]) as PropertyKey[]; - for (const key of keys) { + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + if (!hasOwn(keys, keyIndex)) continue; + const key = keys[keyIndex]!; const descriptorEntry = ReflectApply(ObjectGetOwnPropertyDescriptor, undefined, [ descriptors, key, @@ -263,7 +290,7 @@ function prepareStructuredInstructionMessages(input: { instructions: ChatSystemMessage[]; removeGeneratedSkillContext: boolean; }): ChatSystemMessage[] { - return input.instructions.flatMap((message, index) => { + return flatMapPrivateArray(input.instructions, (message, index) => { const label = `Structured system message ${index}`; const contentValue = readOwnDataProperty(message, "content", label); if (typeof contentValue !== "string") { @@ -305,9 +332,11 @@ function splitStructuredInstructionMessages( const after: ChatSystemMessage[] = []; let foundMarker = false; - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!hasOwn(messages, messageIndex)) continue; + const message = messages[messageIndex]!; if (foundMarker) { - after.push(message); + pushPrivateArray(after, message); continue; } @@ -316,16 +345,16 @@ function splitStructuredInstructionMessages( runtimeContextMarker, }); if (!split.hasMarker) { - before.push(message); + pushPrivateArray(before, message); continue; } foundMarker = true; if (split.before.length > 0) { - before.push({ ...message, content: split.before }); + pushPrivateArray(before, { ...message, content: split.before }); } if (split.after !== null) { - after.push({ ...message, content: split.after }); + pushPrivateArray(after, { ...message, content: split.after }); } } @@ -344,7 +373,7 @@ function buildCacheControl(cacheTtl: AgentCallCacheTtl | undefined): { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } function isAnthropicCacheProviderKey(key: string): boolean { @@ -355,7 +384,7 @@ function isAnthropicCacheProviderKey(key: string): boolean { function getAnthropicCacheProviderAlias(input: BuildAgentCallContextInput): string { const alias = input.anthropicProviderAlias; - return alias && alias.trim().length > 0 ? alias : "veryfront-cloud"; + return alias && privateTextTrim(alias).length > 0 ? alias : "veryfront-cloud"; } function isAnthropicCacheControl(value: unknown): boolean { @@ -386,7 +415,9 @@ function getStructuredCacheProviderBuckets( ): StructuredCacheProviderBucket[] { const buckets: StructuredCacheProviderBucket[] = []; const keys = ReflectApply(ReflectOwnKeys, undefined, [providerOptions]) as PropertyKey[]; - for (const key of keys) { + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + if (!hasOwn(keys, keyIndex)) continue; + const key = keys[keyIndex]!; if (key !== "anthropic" && key !== anthropicProviderAlias) { continue; } @@ -434,7 +465,7 @@ function getStructuredCacheProviderBuckets( ) { continue; } - buckets.push({ + pushPrivateArray(buckets, { key, cacheControl, value: snapshotOwnEnumerableDataRecord( @@ -465,7 +496,9 @@ function removeStructuredCacheControls( } const nextProviderOptions = { ...providerOptions }; - for (const bucket of cacheProviderBuckets) { + for (let bucketIndex = 0; bucketIndex < cacheProviderBuckets.length; bucketIndex++) { + if (!hasOwn(cacheProviderBuckets, bucketIndex)) continue; + const bucket = cacheProviderBuckets[bucketIndex]!; const nextBucket = { ...bucket.value }; ReflectApply(ReflectDeleteProperty, undefined, [nextBucket, "cacheControl"]); if (ReflectOwnKeys(nextBucket).length > 0) { @@ -490,7 +523,9 @@ function hasStructuredCacheControl( messages: readonly ChatSystemMessage[], anthropicProviderAlias: string, ): boolean { - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!hasOwn(messages, messageIndex)) continue; + const message = messages[messageIndex]!; const providerOptions = snapshotOwnEnumerableDataRecord( message.providerOptions, "Structured system message providerOptions", @@ -530,11 +565,13 @@ function applyStructuredCacheTtl( providerOptions, anthropicProviderAlias, ); - const cacheProviderBuckets = structuredCacheProviderBuckets.filter((bucket) => - bucket.cacheControl !== undefined + const cacheProviderBuckets = filterPrivateArray( + structuredCacheProviderBuckets, + (bucket) => bucket.cacheControl !== undefined, ); - const undefinedCacheProviderBuckets = structuredCacheProviderBuckets.filter((bucket) => - bucket.cacheControl === undefined + const undefinedCacheProviderBuckets = filterPrivateArray( + structuredCacheProviderBuckets, + (bucket) => bucket.cacheControl === undefined, ); return { providerOptions, @@ -543,17 +580,19 @@ function applyStructuredCacheTtl( }; }); const breakpointIndexes: number[] = []; - for (const [index, { cacheProviderBuckets }] of cacheMetadata.entries()) { + for (let index = 0; index < cacheMetadata.length; index++) { + if (!hasOwn(cacheMetadata, index)) continue; + const { cacheProviderBuckets } = cacheMetadata[index]!; if (cacheProviderBuckets.length > 0) { - breakpointIndexes.push(index); + pushPrivateArray(breakpointIndexes, index); } } const addCanonicalBreakpoint = cacheMetadata[breakpointIndex]!.cacheProviderBuckets.length === 0; if (addCanonicalBreakpoint) { - breakpointIndexes.push(breakpointIndex); + pushPrivateArray(breakpointIndexes, breakpointIndex); } - const retainedBreakpointIndexes = new Set( - breakpointIndexes.slice(-ANTHROPIC_MAX_CACHE_BREAKPOINTS), + const retainedBreakpointIndexes = createPrivateSet( + slicePrivateArray(breakpointIndexes, -ANTHROPIC_MAX_CACHE_BREAKPOINTS), ); return mapPrivateArray(messages, (message, index) => { @@ -568,7 +607,9 @@ function applyStructuredCacheTtl( } const nextProviderOptions = { ...providerOptions }; - for (const bucket of undefinedCacheProviderBuckets) { + for (let bucketIndex = 0; bucketIndex < undefinedCacheProviderBuckets.length; bucketIndex++) { + if (!hasOwn(undefinedCacheProviderBuckets, bucketIndex)) continue; + const bucket = undefinedCacheProviderBuckets[bucketIndex]!; const nextBucket = { ...bucket.value }; ReflectApply(ReflectDeleteProperty, undefined, [nextBucket, "cacheControl"]); if (ReflectOwnKeys(nextBucket).length > 0) { @@ -582,7 +623,9 @@ function applyStructuredCacheTtl( ReflectApply(ReflectDeleteProperty, undefined, [nextProviderOptions, bucket.key]); } } - for (const bucket of cacheProviderBuckets) { + for (let bucketIndex = 0; bucketIndex < cacheProviderBuckets.length; bucketIndex++) { + if (!hasOwn(cacheProviderBuckets, bucketIndex)) continue; + const bucket = cacheProviderBuckets[bucketIndex]!; if (retainedBreakpointIndexes.has(index)) { ReflectApply(ObjectDefineProperty, undefined, [nextProviderOptions, bucket.key, { configurable: true, @@ -652,7 +695,7 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy )[PRESERVE_RUNTIME_CONTEXT_MARKER] === true; const runtimeContextMarker = input.runtimeContextMarker ?? DEFAULT_RUNTIME_AGENT_CONTEXT_MARKER; const anthropicProviderAlias = getAnthropicCacheProviderAlias(input); - if (Array.isArray(input.instructions)) { + if (isArray(input.instructions)) { const preparedMessages = prepareStructuredInstructionMessages({ instructions: input.instructions, removeGeneratedSkillContext: input.skills !== undefined, @@ -666,23 +709,22 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy input.cacheTtl, anthropicProviderAlias, ); - const flattenedInstructions = flattenSystemInstructions([ - ...splitMessages.before, - ...splitMessages.after, - ]); + const flattenedInstructions = flattenSystemInstructions( + concatPrivateArrays(splitMessages.before, splitMessages.after), + ); const generatedMessages = buildAgentCallContext({ ...input, instructions: flattenedInstructions, }); const dynamicMessage = generatedMessages[flattenedInstructions.length > 0 ? 1 : 0]; - return [ - ...staticMessages, - ...(preserveRuntimeContextMarker && splitMessages.hasMarker + return flatMapPrivateArray([ + staticMessages, + preserveRuntimeContextMarker && splitMessages.hasMarker ? [{ role: "system" as const, content: runtimeContextMarker }] - : []), - ...(dynamicMessage ? [dynamicMessage] : []), - ...removeStructuredCacheControls(splitMessages.after, anthropicProviderAlias), - ]; + : [], + dynamicMessage ? [dynamicMessage] : [], + removeStructuredCacheControls(splitMessages.after, anthropicProviderAlias), + ], (messages) => messages); } const sourceInstructions = input.skills === undefined ? input.instructions : removeCompleteBlocks( removeCompleteBlocks( @@ -705,19 +747,21 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy const dynamicParts: string[] = []; if (preserveRuntimeContextMarker && instructions.hasMarker) { - dynamicParts.push(runtimeContextMarker); + pushPrivateArray(dynamicParts, runtimeContextMarker); } const projectBlocks: string[] = []; if (input.projectInstructions) { - projectBlocks.push(buildProjectInstructionsPromptBlock(input.projectInstructions)); + pushPrivateArray(projectBlocks, buildProjectInstructionsPromptBlock(input.projectInstructions)); } if (input.projectContext) { - projectBlocks.push(buildProjectContextPromptBlock(input.projectContext)); + pushPrivateArray(projectBlocks, buildProjectContextPromptBlock(input.projectContext)); } - projectBlocks.push(...(input.extraBlocks ?? [])); + appendPrivateArray(projectBlocks, input.extraBlocks ?? []); - for (const block of projectBlocks) { + for (let blockIndex = 0; blockIndex < projectBlocks.length; blockIndex++) { + if (!hasOwn(projectBlocks, blockIndex)) continue; + const block = projectBlocks[blockIndex]!; if (block.length === 0) { continue; } @@ -725,13 +769,14 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy if (blockName !== null && hasBlock(sourceInstructions, blockName)) { continue; } - dynamicParts.push(block); + pushPrivateArray(dynamicParts, block); } if (input.skills !== undefined) { const hasAuthoredSkillCatalog = hasBlock(sourceInstructions, AVAILABLE_SKILLS_BLOCK_NAME); if (input.skills.length > 0 || hasAuthoredSkillCatalog) { - dynamicParts.push( + pushPrivateArray( + dynamicParts, hasAuthoredSkillCatalog ? buildRuntimeAuthorizedSkillIdsPromptBlock(input.skills) : buildRuntimeAvailableSkillsPromptBlock(input.skills), @@ -740,7 +785,8 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy } if (input.environmentContext && !hasBlock(sourceInstructions, ENVIRONMENT_CONTEXT_BLOCK_NAME)) { - dynamicParts.push( + pushPrivateArray( + dynamicParts, createRuntimePromptBlock({ name: ENVIRONMENT_CONTEXT_BLOCK_NAME, content: input.environmentContext, @@ -749,7 +795,7 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy } if (instructions.after) { - dynamicParts.push(instructions.after); + pushPrivateArray(dynamicParts, instructions.after); } const messages: ChatSystemMessage[] = staticPrompt.length > 0 @@ -763,9 +809,9 @@ export function buildAgentCallContext(input: BuildAgentCallContextInput): ChatSy : []; if (dynamicParts.length > 0) { - messages.push({ + pushPrivateArray(messages, { role: "system", - content: dynamicParts.join("\n\n"), + content: joinPrivateArray(dynamicParts, "\n\n"), }); } diff --git a/src/agent/streaming/lifecycle/runtime-provider-adapter.test.ts b/src/agent/streaming/lifecycle/runtime-provider-adapter.test.ts index 4fea5af51f..b6088a8e48 100644 --- a/src/agent/streaming/lifecycle/runtime-provider-adapter.test.ts +++ b/src/agent/streaming/lifecycle/runtime-provider-adapter.test.ts @@ -14,6 +14,29 @@ const options = { }; describe("runtime stream Provider Adapter", () => { + it("looks up existing private tools without consulting an own find override", () => { + let reads = 0; + const tools = [{ + id: "call", + name: "create_file", + phase: "input_streaming" as const, + inputText: "synthetic private input", + inputDeltas: [], + }]; + Object.defineProperty(tools, "find", { + get() { + reads++; + return Array.prototype.find; + }, + }); + const signals = decodeRuntimeStreamPart( + { type: "tool-input-delta", id: "call", delta: "next" }, + { ...snapshot, tools }, + options, + ); + assertEquals(signals.length, 1); + assertEquals(reads, 0); + }); it("maps runtime parts to provider-neutral signals", () => { assertEquals( decodeRuntimeStreamPart( diff --git a/src/agent/streaming/lifecycle/runtime-provider-adapter.ts b/src/agent/streaming/lifecycle/runtime-provider-adapter.ts index bbc35ecf2c..19b0655e55 100644 --- a/src/agent/streaming/lifecycle/runtime-provider-adapter.ts +++ b/src/agent/streaming/lifecycle/runtime-provider-adapter.ts @@ -17,6 +17,8 @@ import type { StreamUsage, } from "./types.ts"; +const hasOwn = Object.hasOwn; + export interface RuntimeStreamProviderOptions { availableToolNames: ReadonlySet | null; providerExecutedToolNames: ReadonlySet; @@ -174,7 +176,12 @@ function findTool( snapshot: Readonly, toolCallId: string, ): StreamToolSnapshot | undefined { - return snapshot.tools.find((tool) => tool.id === toolCallId); + for (let index = 0; index < snapshot.tools.length; index++) { + if (!hasOwn(snapshot.tools, index)) continue; + const tool = snapshot.tools[index]!; + if (tool.id === toolCallId) return tool; + } + return undefined; } function isToolAvailable( diff --git a/tests/integration/agent/context-provider-collection-intrinsics.test.ts b/tests/integration/agent/context-provider-collection-intrinsics.test.ts new file mode 100644 index 0000000000..d07a7d2a68 --- /dev/null +++ b/tests/integration/agent/context-provider-collection-intrinsics.test.ts @@ -0,0 +1,93 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { buildAgentCallContext } from "#veryfront/agent/runtime/call-context.ts"; +import { createInitialReducerState } from "#veryfront/agent/streaming/lifecycle/reducer.ts"; +import { decodeRuntimeStreamPart } from "#veryfront/agent/streaming/lifecycle/runtime-provider-adapter.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "arrays", "strings", "find"]) { + describe(`private context and provider collections ${probe}`, () => { + it("assembles project instructions and reads accumulated tool input without shared hooks", () => { + const marker = "synthetic-private-call-context"; + const input = { + instructions: `Base ${marker}Tail`, + projectInstructions: marker, + extraBlocks: [`${marker}`], + }; + const structured = { + ...input, + instructions: [{ role: "system" as const, content: input.instructions }], + }; + const expected = buildAgentCallContext(input); + const expectedStructured = buildAgentCallContext(structured); + const snapshot = createInitialReducerState().snapshot; + snapshot.tools = [{ + id: "call", + name: "inspect", + phase: "input_streaming", + inputText: marker, + inputDeltas: [marker], + }]; + const options = { + availableToolNames: new Set(["inspect"]), + providerExecutedToolNames: new Set(), + }; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; + let observations = 0; + const observe = (value: unknown) => { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + }; + const replace = (target: object, key: PropertyKey) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + observe(this); + if (key === "push") observe(args); + return apply(descriptor.value, this, args); + }, + }); + }; + let messages; + let structuredMessages; + let signals; + try { + if (probe === "arrays") { + for (const key of ["push", "join", "map", "flatMap", "filter", Symbol.iterator]) { + replace(Array.prototype, key); + } + } + if (probe === "strings") { + for (const key of ["indexOf", "slice", "trim", "trimStart", "trimEnd", "startsWith"]) { + replace(String.prototype, key); + } + } + if (probe === "find") replace(Array.prototype, "find"); + messages = buildAgentCallContext(input); + structuredMessages = buildAgentCallContext(structured); + signals = decodeRuntimeStreamPart( + { type: "tool-input-delta", id: "call", delta: "next" }, + snapshot, + options, + ); + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(messages, expected); + assertEquals(structuredMessages, expectedStructured); + assertEquals(signals, [{ + kind: "protocol", + event: { type: "tool_input_content", toolCallId: "call", delta: "next" }, + }]); + assertEquals(observations, 0); + }); + }); +} From 190122da5e9fc1cadca8a3826f4f2d6a0355f8d5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:34:13 +0200 Subject: [PATCH 168/194] fix(agent): retain trusted host aliases through executor installation --- docs/guides/agent-service-runtime.md | 3 + src/agent/hosted/executor-runtime-facades.ts | 4 + .../hosted/executor-runtime-install-schema.ts | 22 ++- .../hosted/executor-runtime-install.test.ts | 39 ++++++ .../hosted/managed-executor-broker.test.ts | 130 ++++++++++++++++++ src/agent/hosted/managed-executor-broker.ts | 22 ++- 6 files changed, 218 insertions(+), 2 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index f20075829d..61395e770a 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -497,3 +497,6 @@ including project-local tools that have no broker capability. The broker resolve owned tools first, then validates source capabilities against those exact IDs. Preparation and steering refreshes receive the same resolved grant. A shadowed global tool does not gain authority from an owned tool's short selector. The catalog must come from trusted source metadata before executor discovery. +For selected host tools, the broker includes the catalog's owner and short-name mapping in the +validated installation. Rebuilt executor facades retain that mapping, so an agent's short selector +continues to select the same canonical tool. Remote source listings do not supply ownership authority. diff --git a/src/agent/hosted/executor-runtime-facades.ts b/src/agent/hosted/executor-runtime-facades.ts index 9603141825..41b48f52c5 100644 --- a/src/agent/hosted/executor-runtime-facades.ts +++ b/src/agent/hosted/executor-runtime-facades.ts @@ -50,8 +50,12 @@ export async function createExecutorRuntimeFacades(options: { const definitions = await source.listTools({ abortSignal: signal }); const tools: HostToolSet = Object.create(null); for (const definition of definitions) { + const alias = input.hostToolAliases?.find((entry) => + entry.sourceId === source.id && entry.toolName === definition.name + ); tools[definition.name] = { id: definition.name, + ...(alias ? { ownerAgentId: alias.ownerAgentId, shortName: alias.shortName } : {}), title: definition.title, description: definition.description, inputSchemaJson: definition.parameters, diff --git a/src/agent/hosted/executor-runtime-install-schema.ts b/src/agent/hosted/executor-runtime-install-schema.ts index 879952d602..88d56db29b 100644 --- a/src/agent/hosted/executor-runtime-install-schema.ts +++ b/src/agent/hosted/executor-runtime-install-schema.ts @@ -9,6 +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"; function artifactShape(v: SchemaValidator) { return { @@ -29,12 +30,31 @@ export const getExecutorRuntimeInstallSchema = defineSchema((v) => ...artifactShape(v), binding: getExecutorBindingSchema(), grant: getExecutorRuntimeGrantDataSchema(), + /** Trusted ownership metadata for selected host tools, independent of source listings. */ + hostToolAliases: v.array( + v.object({ + sourceId: getExecutorToolIdSchema(), + toolName: getExecutorToolIdSchema(), + ownerAgentId: getExecutorDiscoveryIdSchema(), + shortName: getExecutorToolIdSchema(), + }).strict(), + ).max(4096).optional(), capabilities: v.object({ persistence: getExecutorPersistenceCapabilityIdsSchema(), projectSteering: getExecutorDiscoveryIdSchema().optional(), conversationUserText: getExecutorDiscoveryIdSchema().optional(), }).strict(), - }).strict().refine(({ grant, capabilities }) => { + }).strict().refine(({ grant, capabilities, hostToolAliases }) => { + const aliases = new Set(); + for (const alias of hostToolAliases ?? []) { + const key = JSON.stringify([alias.sourceId, alias.toolName]); + if ( + aliases.has(key) || alias.ownerAgentId !== grant.agentId || + !grant.hostToolFacadeIds.includes(alias.sourceId) || + !grant.allowedToolNames.includes(alias.toolName) + ) return false; + aliases.add(key); + } const execution = grant.execution; if ( (execution.projectId !== null || grant.requiredCapabilities?.includes("project-steering")) && diff --git a/src/agent/hosted/executor-runtime-install.test.ts b/src/agent/hosted/executor-runtime-install.test.ts index f061996ed0..5a9c4ec42f 100644 --- a/src/agent/hosted/executor-runtime-install.test.ts +++ b/src/agent/hosted/executor-runtime-install.test.ts @@ -4,6 +4,11 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { createExecutorRuntimeInstallation } from "./executor-runtime-install.ts"; +import { + getExecutorRuntimeInstallSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; +import { assertThrows } from "#veryfront/testing/assert.ts"; const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; const artifact = { @@ -68,6 +73,40 @@ function runtime() { } describe("executor runtime installation", () => { + it("accepts host aliases only for the installed owner, source, and canonical tool", () => { + const alias = { + sourceId: "host", + toolName: "owned-paper", + ownerAgentId: "coder", + shortName: "fetch-paper", + }; + const input = { + ...artifact, + binding, + grant: { ...grant, allowedToolNames: ["owned-paper"], hostToolFacadeIds: ["host"] }, + capabilities: { persistence: {} }, + hostToolAliases: [alias], + }; + const parsed = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), input); + assertEquals(parsed.hostToolAliases, [alias]); + for ( + const aliases of [ + [alias, alias], + [{ ...alias, sourceId: "other" }], + [{ ...alias, toolName: "fetch-paper" }], + [{ ...alias, ownerAgentId: "other" }], + [{ ...alias, shortName: "" }], + ] + ) { + assertThrows(() => + parseExecutorInstallation(getExecutorRuntimeInstallSchema(), { + ...input, + hostToolAliases: aliases, + }) + ); + } + }); + it("registers fixed dispatch before bootstrap snapshots it, and imports only after authenticated installation", async () => { let imports = 0; const loaded = runtime(); diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index d1070e09b4..b6ced27397 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -21,6 +21,12 @@ import { readExecutorInitialCheckpoints } from "./executor-checkpoint-state.ts"; import { executorStateOperations } from "./executor-state-schema.ts"; import { createManagedBrokerPersistence } from "./managed-broker-persistence.ts"; import { FakeTime } from "#std/testing/time"; +import { agent } from "#veryfront/agent/factory.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { createExecutorRuntimeInstallation } from "./executor-runtime-install.ts"; +import { createExecutorRuntimeFacades } from "./executor-runtime-facades.ts"; +import { createExecutorDiscovery } from "./executor-discovery.ts"; +import { createExecutorRuntimePreparation } from "./executor-runtime-prepare.ts"; const modelId = "veryfront-cloud/openai/synthetic"; const owner = { scopeKind: "project" as const, projectId: "project-test" }; @@ -334,6 +340,130 @@ function configureCanonical( } describe("managed executor broker", () => { + it("executes an owned host tool selected by its short alias through installed runtime facades", async () => { + const f = fixture(); + const model = scriptedModel([ + { toolCalls: [{ id: "call", name: "owned-paper", input: {} }] }, + { text: "Complete" }, + ], { only: "stream", modelId: "synthetic", provider: "openai" }); + const executions: string[] = []; + f.input.model.resolver = () => model; + f.input.installation.grant.allowedToolNames = ["fetch-paper"]; + f.input.installation.grant.hostToolFacadeIds = ["host"]; + f.input.tools.catalog = new Map([ + ["fetch-paper", {}], + ["owned-paper", { ownerAgentId: "coder", shortName: "fetch-paper" }], + ]); + f.input.tools.sources = new Map([["host", { + allowedToolNames: new Set(["owned-paper"]), + context: {}, + source: { + id: "host", + listTools: () => + Promise.resolve([{ + name: "owned-paper", + description: "Read a synthetic paper", + parameters: { type: "object", properties: {} }, + }]), + executeTool: (name) => { + executions.push(name); + return Promise.resolve({ text: "Synthetic paper" }); + }, + }, + }]]); + f.input.session.connectTransport = ({ binding }) => { + const outbound = new TransformStream(); + const inbound = new TransformStream(); + const installation = createExecutorRuntimeInstallation({ + binding, + artifact: { version: 1, owner, source, root: "project" }, + async install(input, signal) { + const facades = await createExecutorRuntimeFacades({ input, channel: peer, signal }); + const discovery = createExecutorDiscovery({ + binding, + source, + projectDir: "/synthetic-project", + signal, + backend: { + load: () => + Promise.resolve({ + agents: new Map([[ + "coder", + agent({ + id: "coder", + model: modelId, + system: "Synthetic instructions", + tools: { "fetch-paper": true }, + }), + ]]), + tools: new Map(), + 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" }, + }), + cleanup: () => Promise.resolve(), + }, + }); + return createExecutorRuntimePreparation({ + binding, + source, + facades, + discovery, + grant: { + ...input.grant, + models: new Map(input.grant.models.map(({ id, ...policy }) => [id, policy])), + }, + }); + }, + }); + const peer = createExecutorChannel({ + binding, + transport: { readable: outbound.readable, writable: inbound.writable }, + operations: installation.operations, + }); + return Promise.resolve({ + readable: inbound.readable, + writable: outbound.writable, + async close() { + await installation.close(); + peer.close(); + await peer.settled; + }, + }); + }; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + let runtime: Awaited> | undefined; + try { + runtime = await broker.start(f.input); + runtime.accept({ kind: "execution" }); + const stream = await runtime.agent.stream({ + messages: [{ + id: "user", + role: "user", + parts: [{ type: "text", text: "Read" }], + timestamp: 1, + }], + abortSignal: new AbortController().signal, + }); + const events = await Array.fromAsync(stream.toUIMessageStream()); + assertEquals(model.toolNames(), ["owned-paper"]); + assertEquals(executions, ["owned-paper"]); + assertEquals(model.callCount, 2); + assert(events.some((event) => event.type === "finish")); + } finally { + await runtime?.close(); + await broker.shutdown(); + await broker.settled; + } + }); + for (const mismatch of ["output tokens", "provider tools", "tool allowlist", "tool source"]) { it(`rejects a broker ${mismatch} grant broader than its installation before allocation`, async () => { const f = fixture(); diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index e633f89bb1..b68777511a 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -128,7 +128,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption generation: 1, invocationId: input.session.request.invocationId, }; - const installation = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), { + let installation = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), { ...input.installation, binding: validationBinding, }); @@ -145,6 +145,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption const allowedModelIds = new Set(installation.grant.models.map((model) => model.id)); const operationInput = snapshotOperationInput(input); constrainInstalledOperationGrants(operationInput, installation); + installation = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), installation); const bindSessionOwnedWork = input.bindSessionOwnedWork; if ( installation.grant.execution.kind === "ephemeral" && @@ -327,6 +328,25 @@ function constrainInstalledOperationGrants( } } installation.grant.allowedToolNames = [...allowedTools]; + // Source listings describe callable tools; only trusted ingress supplies ownership. + const hostToolAliases: NonNullable = []; + for (const sourceId of installation.grant.hostToolFacadeIds) { + for (const toolName of input.tools.sources.get(sourceId)?.allowedToolNames ?? []) { + const metadata = input.tools.catalog.get(toolName); + if ( + metadata?.ownerAgentId === installation.grant.agentId && metadata.shortName !== undefined + ) { + hostToolAliases.push({ + sourceId, + toolName, + ownerAgentId: metadata.ownerAgentId, + shortName: metadata.shortName, + }); + } + } + } + if (hostToolAliases.length) installation.hostToolAliases = hostToolAliases; + else delete installation.hostToolAliases; } function installedToolNames( From 3f0573b2bda8a093ca19cb750043b0689814a663 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:36:20 +0200 Subject: [PATCH 169/194] fix(agent): protect framework tool search normalization --- src/agent/runtime/call-context.ts | 6 +- src/agent/runtime/index.ts | 11 +- src/agent/runtime/tool-exposure.test.ts | 18 ++ src/agent/runtime/tool-exposure.ts | 225 ++++++++++++------ .../tool-search-private-intrinsics.test.ts | 106 +++++++++ 5 files changed, 288 insertions(+), 78 deletions(-) create mode 100644 tests/integration/agent/tool-search-private-intrinsics.test.ts diff --git a/src/agent/runtime/call-context.ts b/src/agent/runtime/call-context.ts index c86eacc54c..dbfa10533d 100644 --- a/src/agent/runtime/call-context.ts +++ b/src/agent/runtime/call-context.ts @@ -7,6 +7,7 @@ import { mapPrivateArray, pushPrivateArray, slicePrivateArray, + somePrivateArray, } from "#veryfront/security/private-array.ts"; import { privateTextIndexOf, @@ -531,8 +532,9 @@ function hasStructuredCacheControl( "Structured system message providerOptions", ); if ( - getStructuredCacheProviderBuckets(providerOptions, anthropicProviderAlias).some((bucket) => - bucket.cacheControl !== undefined + somePrivateArray( + getStructuredCacheProviderBuckets(providerOptions, anthropicProviderAlias), + (bucket) => bucket.cacheControl !== undefined, ) ) { return true; diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 8f113ad674..f37376716e 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -13,6 +13,7 @@ import { PrivateTextEncoder, privateTextSlice, privateTextStartsWith, + privateTextTrim, } from "#veryfront/security/private-text.ts"; /** * Agent Runtime - Core execution engine @@ -1135,18 +1136,22 @@ function executeFrameworkToolSearch(input: { result: ReturnType & { nextStep: string }; checkpoint: ReturnType; } { - const query = typeof input.args.query === "string" ? input.args.query.trim() : ""; + const query = typeof input.args.query === "string" ? privateTextTrim(input.args.query) : ""; if (!query) { throw new Error('tool_search requires a non-empty "query" string'); } const result: ToolSearchResult = searchToolExposure({ query, authorized: input.plan.deferred, - available: input.plan.visible.filter((tool) => tool.name !== TOOL_SEARCH_TOOL_NAME), + available: filterPrivateArray( + input.plan.visible, + (tool) => tool.name !== TOOL_SEARCH_TOOL_NAME, + ), state: input.state, maxLoadedTools: input.plan.maxLoadedTools, }); - const alreadyVisible = result.matches.find((match) => match.status === "available"); + const alreadyVisible = + filterPrivateArray(result.matches, (match) => match.status === "available")[0]; return { result: { ...result, diff --git a/src/agent/runtime/tool-exposure.test.ts b/src/agent/runtime/tool-exposure.test.ts index 6264e5a89c..0045331201 100644 --- a/src/agent/runtime/tool-exposure.test.ts +++ b/src/agent/runtime/tool-exposure.test.ts @@ -36,6 +36,24 @@ const catalog = [ definition("load_skill", "Load a configured skill"), ]; +it("searches private catalog entries without invoking their iterator", () => { + const authorized = [definition("lookup", "synthetic private search")]; + let reads = 0; + Object.defineProperty(authorized, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + const result = searchToolExposure({ + query: "synthetic private search", + authorized, + state: createToolExposureState(), + }); + assertEquals(result.matches[0]?.name, "lookup"); + assertEquals(reads, 0); +}); + it("copies authorized tools without a mutable array iterator", () => { const allowed = [definition("allowed", "Allowed tool")]; const denied = definition("denied", "Denied tool"); diff --git a/src/agent/runtime/tool-exposure.ts b/src/agent/runtime/tool-exposure.ts index ba7fb2af8d..2d58df215c 100644 --- a/src/agent/runtime/tool-exposure.ts +++ b/src/agent/runtime/tool-exposure.ts @@ -1,4 +1,23 @@ -import { encodePrivateText, PrivateTextEncoder } from "#veryfront/security/private-text.ts"; +import { + encodePrivateText, + privateTextCharCodeAt, + PrivateTextEncoder, + privateTextIncludes, + privateTextIndexOf, + privateTextSlice, + privateTextToLowerCase, + privateTextTrim, +} from "#veryfront/security/private-text.ts"; +import { + everyPrivateArray, + filterPrivateArray, + mapPrivateArray, + pushPrivateArray, + slicePrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { replacePrivateRegExp, testPrivateRegExp } from "#veryfront/security/private-regexp.ts"; import { privateByteLength } from "#veryfront/security/private-bytes.ts"; import type { ToolDefinition } from "#veryfront/tool"; import { parseIntegrationToolIdentity } from "#veryfront/integrations/source-policy.ts"; @@ -6,6 +25,13 @@ import type { RuntimeToolLoadingMode } from "./runtime-tool-config.ts"; import { isOwnDataPropertyDescriptor } from "./data-property-descriptor.ts"; const ArraySort = Array.prototype.sort; +const hasOwn = Object.hasOwn; +const fromCharCode = String.fromCharCode; +const RegExpConstructor = RegExp; +function sortSearchItems(items: T[], compare: (a: T, b: T) => number): T[] { + ReflectApply(ArraySort, items, [compare]); + return items; +} const SetAdd = Set.prototype.add; const SetHas = Set.prototype.has; @@ -16,7 +42,7 @@ function setHas(set: ReadonlySet, value: T): boolean { /** Framework-owned model-facing tool used to load authorized schemas. */ export const TOOL_SEARCH_TOOL_NAME = "tool_search"; -const DEFAULT_BOOTSTRAP_TOOL_NAMES = new Set(["load_skill"]); +const DEFAULT_BOOTSTRAP_TOOL_NAMES = createPrivateSet(["load_skill"]); const TOOL_SEARCH_RESULT_LIMIT = 5; /** Which field a query term matched on, strongest evidence first. */ type ToolSearchMatchField = "exactName" | "name" | "description" | "parameterDescription"; @@ -110,8 +136,12 @@ type SchemaSearchBudget = { }; function normalizeSearchText(value: string): string { - return value.replace(/[A-Z]/g, (character) => String.fromCharCode(character.charCodeAt(0) + 32)) - .replaceAll("_", " ").trim().replace(/\s+/g, " "); + let lower = ""; + for (let index = 0; index < value.length; index++) { + const code = privateTextCharCodeAt(value, index); + lower += code >= 65 && code <= 90 ? fromCharCode(code + 32) : value[index]; + } + return replacePrivateRegExp(/\s+/g, privateTextTrim(replacePrivateRegExp(/_/g, lower, " ")), " "); } /** @@ -125,8 +155,8 @@ function normalizeSearchText(value: string): string { function parseCanonicalIntegrationQuery( query: string, ): { namespace: string; canonicalName: string | null } | null { - const trimmed = query.trim().toLowerCase(); - const separator = trimmed.indexOf("__"); + const trimmed = privateTextToLowerCase(privateTextTrim(query)); + const separator = privateTextIndexOf(trimmed, "__"); if (separator <= 0) return null; const identity = parseIntegrationToolIdentity(trimmed); @@ -142,7 +172,7 @@ function parseCanonicalIntegrationQuery( // tool even when the rest is malformed (`jira__list__projects`). Keep such a // query on the namespace path: otherwise normalization collapses it onto a // local id like `jira_list_projects`, which then wins the phrase match. - const namespace = trimmed.slice(0, separator); + const namespace = privateTextSlice(trimmed, 0, separator); return parseIntegrationToolIdentity(`${namespace}__placeholder`) === null ? null : { namespace, canonicalName: null }; @@ -157,8 +187,8 @@ function parseCanonicalIntegrationQuery( * that is not alphanumeric. */ function createNamespaceTokenPattern(namespaceTerm: string): RegExp { - const escaped = namespaceTerm.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); - return new RegExp(`(? 0) { - const current = stack.pop(); + const current = stack[stack.length - 1]; + stack.length--; if (!current || current.depth > TOOL_SEARCH_SCHEMA_MAX_DEPTH) return null; nodes += 1; aggregate.nodes += 1; @@ -257,7 +288,9 @@ function snapshotSchemaDescriptions( arrayLength = length; } - for (const key of keys) { + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + if (!hasOwn(keys, keyIndex)) continue; + const key = keys[keyIndex]!; if (isArray && key === "length") continue; if (typeof key !== "string" || !debitBytes(key)) return null; if (isArray) { @@ -273,9 +306,9 @@ function snapshotSchemaDescriptions( ]) as PropertyDescriptor | undefined; if (!isOwnDataPropertyDescriptor(descriptor) || !descriptor.enumerable) return null; if (key === "description" && typeof descriptor.value === "string") { - descriptions.push(normalizeSearchText(descriptor.value)); + pushPrivateArray(descriptions, normalizeSearchText(descriptor.value)); } - stack.push({ value: descriptor.value, depth: current.depth + 1 }); + pushPrivateArray(stack, { value: descriptor.value, depth: current.depth + 1 }); } } } catch { @@ -332,9 +365,12 @@ function getMatchedField( tool: SearchableTool, ): ToolSearchMatchField | null { if (tool.normalizedName === query) return "exactName"; - if (tool.normalizedName.includes(query)) return "name"; - if (tool.normalizedDescription.includes(query)) return "description"; - return tool.parameterDescriptions.some((description) => description.includes(query)) + if (privateTextIncludes(tool.normalizedName, query)) return "name"; + if (privateTextIncludes(tool.normalizedDescription, query)) return "description"; + return somePrivateArray( + tool.parameterDescriptions, + (description) => privateTextIncludes(description, query), + ) ? "parameterDescription" : null; } @@ -347,11 +383,13 @@ function collectSearchCandidates(input: { const candidates: SearchableTool[] = []; let examinedCandidates = 0; const append = (tools: readonly ToolDefinition[], status: ToolSearchMatch["status"]): void => { - for (const tool of tools) { + for (let toolIndex = 0; toolIndex < tools.length; toolIndex++) { + if (!hasOwn(tools, toolIndex)) continue; + const tool = tools[toolIndex]!; if (examinedCandidates >= TOOL_SEARCH_CANDIDATE_LIMIT) return; examinedCandidates += 1; const snapshot = snapshotSearchableTool(tool, status, budget); - if (snapshot) candidates.push(snapshot); + if (snapshot) pushPrivateArray(candidates, snapshot); } }; append(input.available, "available"); @@ -365,19 +403,24 @@ function rankWholeQueryMatches( candidates: readonly SearchableTool[], ): ToolSearchMatch[] { const ranked: { precedence: number; match: ToolSearchMatch }[] = []; - for (const candidate of candidates) { + for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { + if (!hasOwn(candidates, candidateIndex)) continue; + const candidate = candidates[candidateIndex]!; const field = getMatchedField(query, candidate); if (field === null) continue; - ranked.push({ + pushPrivateArray(ranked, { precedence: TOOL_SEARCH_FIELD_PRECEDENCE[field], match: toSearchMatch(candidate), }); } - return ranked - .sort((left, right) => - left.precedence - right.precedence || compareToolSearchMatches(left.match, right.match) - ) - .map(({ match }) => match); + return mapPrivateArray( + sortSearchItems( + ranked, + (left, right) => + left.precedence - right.precedence || compareToolSearchMatches(left.match, right.match), + ), + ({ match }) => match, + ); } /** @@ -397,9 +440,11 @@ function scoreToolExposureTerms( const total = candidates.length; if (total === 0 || terms.length === 0) return []; - const weightedTerms = terms.map((term) => { + const weightedTerms = mapPrivateArray(terms, (term) => { let documentFrequency = 0; - for (const candidate of candidates) { + for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { + if (!hasOwn(candidates, candidateIndex)) continue; + const candidate = candidates[candidateIndex]!; if (getMatchedField(term, candidate) !== null) documentFrequency += 1; } return { @@ -410,17 +455,21 @@ function scoreToolExposureTerms( : Math.log((total + 1) / (documentFrequency + 0.5)), }; }); - const averageDocumentFrequency = weightedTerms.reduce( - (sum, { documentFrequency }) => sum + documentFrequency, - 0, - ) / weightedTerms.length; + let documentFrequencyTotal = 0; + for (let index = 0; index < weightedTerms.length; index++) { + documentFrequencyTotal += weightedTerms[index]!.documentFrequency; + } + const averageDocumentFrequency = documentFrequencyTotal / weightedTerms.length; const scored: { score: number; match: ToolSearchMatch }[] = []; - for (const candidate of candidates) { + for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { + if (!hasOwn(candidates, candidateIndex)) continue; + const candidate = candidates[candidateIndex]!; let score = 0; let matchedTermCount = 0; let matchedSelectiveTerm = false; - for (const { term, documentFrequency, inverseDocumentFrequency } of weightedTerms) { + for (let index = 0; index < weightedTerms.length; index++) { + const { term, documentFrequency, inverseDocumentFrequency } = weightedTerms[index]!; const field = getMatchedField(term, candidate); if (field === null) continue; matchedTermCount += 1; @@ -432,14 +481,17 @@ function scoreToolExposureTerms( // however common those terms are: in a one-tool catalog every term matches // everything, so the floor alone would report a certain match as a miss. if (!matchedSelectiveTerm && matchedTermCount < terms.length) continue; - scored.push({ score, match: toSearchMatch(candidate) }); + pushPrivateArray(scored, { score, match: toSearchMatch(candidate) }); } - return scored - .sort((left, right) => - right.score - left.score || compareToolSearchMatches(left.match, right.match) - ) - .map(({ match }) => match); + return mapPrivateArray( + sortSearchItems( + scored, + (left, right) => + right.score - left.score || compareToolSearchMatches(left.match, right.match), + ), + ({ match }) => match, + ); } function rankToolExposureMatches(input: { @@ -460,10 +512,16 @@ function rankToolExposureMatches(input: { const canonical = parseCanonicalIntegrationQuery(input.query); if (canonical !== null) { const canonicalName = canonical.canonicalName; - const exact = canonicalName === null ? [] : candidates - .filter((candidate) => candidate.name.toLowerCase() === canonicalName) - .map(toSearchMatch) - .sort(compareToolSearchMatches); + const exact = canonicalName === null ? [] : sortSearchItems( + mapPrivateArray( + filterPrivateArray( + candidates, + (candidate) => privateTextToLowerCase(candidate.name) === canonicalName, + ), + toSearchMatch, + ), + compareToolSearchMatches, + ); if (exact.length > 0) return exact; // Namespace discovery accepts two kinds of evidence, and a coincidental name @@ -476,15 +534,18 @@ function rankToolExposureMatches(input: { // candidate's text has already had underscores rewritten to spaces. const namespaceTerm = normalizeSearchText(canonical.namespace); const namespacePattern = createNamespaceTokenPattern(namespaceTerm); - const namespaceCandidates = candidates.filter((candidate) => { - const identity = parseIntegrationToolIdentity(candidate.name.toLowerCase()); + const namespaceCandidates = filterPrivateArray(candidates, (candidate) => { + const identity = parseIntegrationToolIdentity(privateTextToLowerCase(candidate.name)); if (identity !== null) return identity.integration === canonical.namespace; // A non-canonical tool carrying the namespace in its *name* is a // normalization coincidence, not the integration, whatever its description // happens to mention: `jira_list_projects` is a local tool, not Jira. - if (namespacePattern.test(candidate.normalizedName)) return false; - return namespacePattern.test(candidate.normalizedDescription) || - candidate.parameterDescriptions.some((description) => namespacePattern.test(description)); + if (testPrivateRegExp(namespacePattern, candidate.normalizedName)) return false; + return testPrivateRegExp(namespacePattern, candidate.normalizedDescription) || + somePrivateArray( + candidate.parameterDescriptions, + (description) => testPrivateRegExp(namespacePattern, description), + ); }); return rankWholeQueryMatches(namespaceTerm, namespaceCandidates); } @@ -493,7 +554,14 @@ function rankToolExposureMatches(input: { const wholeQueryMatches = rankWholeQueryMatches(query, candidates); if (wholeQueryMatches.length > 0) return wholeQueryMatches; - const terms = [...new Set(query.split(/\s+/).filter(Boolean))]; + const uniqueTerms = createPrivateSet(); + let start = 0; + for (let index = 0; index <= query.length; index++) { + if (index !== query.length && query[index] !== " ") continue; + if (index > start) uniqueTerms.add(privateTextSlice(query, start, index)); + start = index + 1; + } + const terms = [...uniqueTerms]; return terms.length >= 2 ? scoreToolExposureTerms(terms, candidates) : []; } @@ -501,7 +569,7 @@ function rankToolExposureMatches(input: { export function createToolExposureState( loadedToolNames: Iterable = [], ): ToolExposureState { - return { loadedToolNames: new Set(loadedToolNames) }; + return { loadedToolNames: createPrivateSet(loadedToolNames) }; } function retainNewestLoadedToolNames(state: ToolExposureState, limit: number | undefined): void { @@ -573,7 +641,7 @@ export function createToolExposurePlan(input: { const bootstrap = input.bootstrapToolNames ?? DEFAULT_BOOTSTRAP_TOOL_NAMES; let bootstrapCount = 0; const loadable: ToolDefinition[] = []; - const loadableNames = new Set(); + const loadableNames = createPrivateSet(); for (let index = 0; index < authorized.length; index++) { const tool = authorized[index]!; if (setHas(bootstrap, tool.name)) bootstrapCount += 1; @@ -591,7 +659,7 @@ export function createToolExposurePlan(input: { pruneLoadedToolNames(input.state, loadableNames); retainNewestLoadedToolNames(input.state, maxLoadedTools); const visible: ToolDefinition[] = []; - const visibleNames = new Set(); + const visibleNames = createPrivateSet(); for (let index = 0; index < authorized.length; index++) { const tool = authorized[index]!; if (setHas(bootstrap, tool.name) || setHas(input.state.loadedToolNames, tool.name)) { @@ -637,9 +705,11 @@ export function searchToolExposure(input: { authorized: input.authorized, }); if (ranked[0]?.status === "available") { - const matches = ranked - .filter((match) => match.status === "available") - .slice(0, TOOL_SEARCH_RESULT_LIMIT); + const matches = slicePrivateArray( + filterPrivateArray(ranked, (match) => match.status === "available"), + 0, + TOOL_SEARCH_RESULT_LIMIT, + ); return { matches, resultCount: matches.length, @@ -648,21 +718,25 @@ export function searchToolExposure(input: { }; } - const matches = ranked - .filter((match) => match.status === "loaded") - .slice( - 0, - input.maxLoadedTools === undefined - ? TOOL_SEARCH_RESULT_LIMIT - : Math.min(TOOL_SEARCH_RESULT_LIMIT, input.maxLoadedTools), - ); - - for (const match of matches) { + const matches = slicePrivateArray( + filterPrivateArray(ranked, (match) => match.status === "loaded"), + 0, + input.maxLoadedTools === undefined + ? TOOL_SEARCH_RESULT_LIMIT + : Math.min(TOOL_SEARCH_RESULT_LIMIT, input.maxLoadedTools), + ); + + for (let matchIndex = 0; matchIndex < matches.length; matchIndex++) { + if (!hasOwn(matches, matchIndex)) continue; + const match = matches[matchIndex]!; input.state.loadedToolNames.delete(match.name); input.state.loadedToolNames.add(match.name); } retainNewestLoadedToolNames(input.state, input.maxLoadedTools); - const loadedMatches = matches.filter((match) => input.state.loadedToolNames.has(match.name)); + const loadedMatches = filterPrivateArray( + matches, + (match) => input.state.loadedToolNames.has(match.name), + ); return { matches: loadedMatches, @@ -677,11 +751,13 @@ export function createToolExposureCheckpoint( authorized: readonly ToolDefinition[], state: ToolExposureState, ): ToolExposureCheckpoint { - const authorizedNames = new Set(authorized.map((tool) => tool.name)); + const authorizedNames = createPrivateSet(mapPrivateArray(authorized, (tool) => tool.name)); return { version: 2, - loadedToolNames: [...state.loadedToolNames] - .filter((name) => authorizedNames.has(name)), + loadedToolNames: filterPrivateArray( + [...state.loadedToolNames], + (name) => authorizedNames.has(name), + ), }; } @@ -709,13 +785,16 @@ export function restoreToolExposureState( if ( !isSupportedToolExposureCheckpointVersion(checkpoint?.version) || !ArrayIsArray(checkpoint.loadedToolNames) || - !checkpoint.loadedToolNames.every(isValidToolExposureCheckpointName) + !everyPrivateArray(checkpoint.loadedToolNames, isValidToolExposureCheckpointName) ) { return createToolExposureState(); } - const authorizedNames = new Set(authorized.map((tool) => tool.name)); - const loadedToolNames = checkpoint.loadedToolNames.filter((name) => authorizedNames.has(name)); - if (checkpoint.version === 1) loadedToolNames.sort(compareAscii); + const authorizedNames = createPrivateSet(mapPrivateArray(authorized, (tool) => tool.name)); + const loadedToolNames = filterPrivateArray( + checkpoint.loadedToolNames, + (name) => authorizedNames.has(name), + ); + if (checkpoint.version === 1) sortSearchItems(loadedToolNames, compareAscii); return createToolExposureState(loadedToolNames); } diff --git a/tests/integration/agent/tool-search-private-intrinsics.test.ts b/tests/integration/agent/tool-search-private-intrinsics.test.ts new file mode 100644 index 0000000000..ea400046fe --- /dev/null +++ b/tests/integration/agent/tool-search-private-intrinsics.test.ts @@ -0,0 +1,106 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import type { AgentConfig } from "#veryfront/agent/types.ts"; +import type { RuntimeToolFilterConfig } from "#veryfront/agent/runtime/runtime-tool-config.ts"; +import { buildAgentCallContext } from "#veryfront/agent/runtime/call-context.ts"; +import { tool } from "#veryfront/tool"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const probe of ["baseline", "strings", "arrays"]) { + describe(`private framework tool search ${probe}`, () => { + it("loads and executes a matching tool while preserving structured cache metadata", async () => { + const marker = "synthetic private search"; + const model = scriptedModel([ + { toolCalls: [{ id: "search", name: "tool_search", input: { query: ` ${marker} ` } }] }, + { toolCalls: [{ id: "lookup", name: "lookup_private", input: {} }] }, + { text: "Complete" }, + ], { only: "stream" }); + let executions = 0; + const config: AgentConfig & RuntimeToolFilterConfig = { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + skills: false, + maxSteps: 3, + __vfToolLoadingMode: "deferred", + tools: { + lookup_private: tool({ + id: "lookup_private", + description: marker, + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + executions++; + return { ok: true }; + }, + }), + }, + }; + const runtime = createEphemeralAgentWithRuntimeOptions(config, { + resolveModelRuntime: () => model, + }); + const contextInput = { + instructions: [{ + role: "system" as const, + content: "Instructions", + providerOptions: { anthropic: { cacheControl: { type: "ephemeral" }, custom: marker } }, + }], + }; + const expectedContext = buildAgentCallContext(contextInput); + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; + let observations = 0; + const replace = (target: object, key: PropertyKey) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (apply(includes, stringify(this) ?? "", [marker])) observations++; + return apply(descriptor.value, this, args); + }, + }); + }; + let context; + let output = ""; + try { + if (probe === "strings") { + for ( + const key of [ + "trim", + "toLowerCase", + "replace", + "replaceAll", + "indexOf", + "slice", + "split", + "includes", + ] + ) replace(String.prototype, key); + } + if (probe === "arrays") { + for (const key of ["some", "map", "filter", "reduce", "sort", Symbol.iterator]) { + replace(Array.prototype, key); + } + } + context = buildAgentCallContext(contextInput); + output = await (await runtime.stream({ input: "Find and use the lookup tool" })) + .toDataStreamResponse().text(); + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(context, expectedContext); + assertEquals(executions, 1); + assertEquals(model.callCount, 3); + assertStringIncludes(output, "Complete"); + assertEquals(observations, 0); + }); + }); +} From 680c9632f4f2b944fbe3751c60e3b994d6d96112 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:43:21 +0200 Subject: [PATCH 170/194] test(agent): distinguish private search query from authored metadata --- .../agent/tool-search-private-intrinsics.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/integration/agent/tool-search-private-intrinsics.test.ts b/tests/integration/agent/tool-search-private-intrinsics.test.ts index ea400046fe..38198d413c 100644 --- a/tests/integration/agent/tool-search-private-intrinsics.test.ts +++ b/tests/integration/agent/tool-search-private-intrinsics.test.ts @@ -28,7 +28,8 @@ for (const probe of ["baseline", "strings", "arrays"]) { tools: { lookup_private: tool({ id: "lookup_private", - description: marker, + // The query's complete marker must not also appear in authored tool metadata. + description: "Search private synthetic records", inputSchema: defineSchema((v) => v.object({}))(), execute: () => { executions++; @@ -54,13 +55,19 @@ for (const probe of ["baseline", "strings", "arrays"]) { const defineProperty = Object.defineProperty; const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; let observations = 0; + const observationSites: string[] = []; const replace = (target: object, key: PropertyKey) => { const descriptor = Object.getOwnPropertyDescriptor(target, key)!; originals.push({ target, key, descriptor }); defineProperty(target, key, { ...descriptor, value: function (this: unknown, ...args: unknown[]) { - if (apply(includes, stringify(this) ?? "", [marker])) observations++; + if (apply(includes, stringify(this) ?? "", [marker])) { + observations++; + if (observationSites.length < 5) { + observationSites.push(`${String(key)}: ${new Error().stack}`); + } + } return apply(descriptor.value, this, args); }, }); @@ -100,7 +107,7 @@ for (const probe of ["baseline", "strings", "arrays"]) { assertEquals(executions, 1); assertEquals(model.callCount, 3); assertStringIncludes(output, "Complete"); - assertEquals(observations, 0); + assertEquals(observations, 0, observationSites.join("\n")); }); }); } From edbe5144d4ea59ee21efa730c26324fca7b43d96 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:50:19 +0200 Subject: [PATCH 171/194] fix(agent): reserve reasoning tokens in prepared model grants --- docs/guides/agent-service-runtime.md | 5 +- src/agent/hosted/executor-model-grant.ts | 22 ++- .../hosted/executor-runtime-prepare.test.ts | 138 ++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 27 +++- 4 files changed, 180 insertions(+), 12 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 61395e770a..b06058ea21 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -488,7 +488,10 @@ service list after the first heartbeat Broker model output limits and provider-tool descriptors must stay within the installed model grant. Each broker tool capability must also stay within the installed tool allowlist. Startup rejects broader broker authority before allocating an executor. Preparation uses the narrower broker model output -limits and provider-tool list, so its default model requests fit the broker policy. Source IDs must +limits and provider-tool list, so its default model requests fit the broker policy. Anthropic thinking +with an additive token budget reserves that budget from the total allowance before preparation chooses +the completion limit. Adaptive thinking uses the total allowance without an additive reservation. +Preparation rejects an explicit completion limit that exceeds the remainder. Source IDs must belong to the installed host-facade or remote-source grants. Owner-scoped tool selectors use the same canonical names for capability checks and steering refreshes. diff --git a/src/agent/hosted/executor-model-grant.ts b/src/agent/hosted/executor-model-grant.ts index d752b0cb3b..6c1d466aba 100644 --- a/src/agent/hosted/executor-model-grant.ts +++ b/src/agent/hosted/executor-model-grant.ts @@ -1,5 +1,9 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; -import type { ModelRuntimeToolDefinition } from "#veryfront/provider/types.ts"; +import type { + ModelRuntimeCallOptions, + ModelRuntimeToolDefinition, + RuntimeMetadata, +} from "#veryfront/provider/types.ts"; import { createExecutorModelFailure } from "./executor-model-errors.ts"; import { buildModelCallContextRequest, @@ -75,10 +79,14 @@ function assertSingleCompletion(options: ExecutorModelDispatch["options"]): void for (const bucket of Object.values(options.providerOptions ?? {})) inspect(bucket); } -function additiveReasoningTokens(request: ExecutorModelDispatch): number { - if (resolveModelCallProvider(request.model) !== "anthropic") return 0; - const reasoning = buildModelCallContextRequest(request.model, request.options)?.reasoning; - if (request.options.reasoning?.enabled === true) { +/** @internal Shared additive output budget for preparation and broker admission. */ +export function executorAdditiveReasoningTokens( + model: Pick, + options: Pick, +): number { + if (resolveModelCallProvider(model) !== "anthropic") return 0; + const reasoning = buildModelCallContextRequest(model, options)?.reasoning; + if (options.reasoning?.enabled === true) { const budget = reasoning?.budgetTokens ?? (reasoning?.effort === "low" ? 1024 @@ -95,7 +103,7 @@ function additiveReasoningTokens(request: ExecutorModelDispatch): number { } // Canonical native thinking has precedence when neutral reasoning does not enable it. // Adaptive thinking stays within max_tokens and adds no separate budget. - const anthropic = request.options.providerOptions?.anthropic; + const anthropic = options.providerOptions?.anthropic; const thinking = anthropic && typeof anthropic === "object" && !Array.isArray(anthropic) ? (anthropic as Record).thinking : undefined; @@ -188,7 +196,7 @@ export function createExecutorModelAdmission( const policy = policies.get(request.model.id); if (!policy) throw new TypeError("Executor model is not granted"); assertSingleCompletion(request.options); - const budget = additiveReasoningTokens(request); + const budget = executorAdditiveReasoningTokens(request.model, request.options); const available = policy.maxOutputTokens - budget; const maxOutputTokens = request.options.maxOutputTokens ?? available; if ( diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index b1ed59e3f2..abdc8b9ef1 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -22,6 +22,10 @@ import { import { ExecutorRuntimePreparationError } from "./executor-runtime-prepare-schema.ts"; import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; import { createExecutorHostedChatRuntimeAgent } from "./executor-agent-bridge.ts"; +import { createExecutorModelRuntimeResolver } from "./executor-model-bridge.ts"; +import { createEphemeralHostedExecutorModelBroker } from "./executor-model-dispatch.ts"; +import { scriptedModel } from "../runtime/model-runtime.test-helpers.ts"; +import { executorAgentJson } from "./executor-agent-schema.ts"; const binding = { allocationId: "prepare-allocation", @@ -149,6 +153,140 @@ async function prepare( } describe("executor runtime preparation", () => { + for ( + const request of [ + { thinking: { enabled: true, budgetTokens: 8192 } }, + { thinking: { enabled: true, budgetTokens: 4096 }, maxOutputTokens: 4097 }, + ] + ) { + it("rejects a completion allowance exhausted or exceeded by thinking before streaming", async () => { + const id = "veryfront-cloud/anthropic/claude-sonnet-4-6"; + const f = fixture({ + grant: { + ...grant, + defaultModelId: id, + models: new Map([[id, { maxOutputTokens: 8192, providerToolNames: [] }]]), + }, + config: { model: id }, + facades: { + resolveModelRuntime: () => ({ + ...model, + provider: "anthropic", + modelId: "claude-sonnet-4-6", + }), + }, + }); + try { + assertEquals( + await prepare( + f.owner, + executorAgentJson({ agentId: "coder", ...request }, "EXECUTOR_AGENT_INPUT_TOO_LARGE"), + ), + { + ok: false, + code: "EXECUTOR_RUNTIME_NOT_GRANTED", + }, + ); + } finally { + await f.owner.close(); + } + }); + } + + for ( + const scenario of [ + { name: "catalog thinking", model: "claude-sonnet-4-6", expected: 6144 }, + { + name: "explicit thinking", + model: "claude-sonnet-4-6", + thinking: { enabled: true, budgetTokens: 4096 }, + expected: 4096, + }, + { + name: "default enabled thinking", + model: "claude-sonnet-4-6", + thinking: { enabled: true }, + expected: 4096, + }, + { + name: "disabled thinking", + model: "claude-sonnet-4-6", + thinking: { enabled: false }, + expected: 8192, + }, + { name: "adaptive thinking", model: "claude-opus-4-8", expected: 8192 }, + { name: "explicit completion", model: "claude-sonnet-4-6", completion: 512, expected: 512 }, + ] as const + ) { + it(`fits ${scenario.name} inside the broker's total output allowance`, async () => { + const id = `veryfront-cloud/anthropic/${scenario.model}`; + const provider = scriptedModel([{ text: "Complete" }], { + provider: "anthropic", + modelId: scenario.model, + only: "stream", + }); + const forward = new TransformStream(); + const backward = new TransformStream(); + const broker = createExecutorChannel({ + binding, + transport: { readable: forward.readable, writable: backward.writable }, + operations: createEphemeralHostedExecutorModelBroker({ + resolveModelRuntime: () => provider, + allowedModelIds: new Set([id]), + scope: { binding, signal: new AbortController().signal, assertActive() {} }, + grant: { + maxCalls: 2, + maxConcurrentCalls: 1, + models: new Map([[id, { maxOutputTokens: 8192, providerTools: [] }]]), + }, + prepared: { conversationId: null, canonicalRootRun: null }, + }), + }); + const executor = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const resolveModelRuntime = await createExecutorModelRuntimeResolver({ + channel: executor, + allowedModelIds: new Set([id]), + }); + const f = fixture({ + grant: { + ...grant, + defaultModelId: id, + models: new Map([[id, { maxOutputTokens: 8192, providerToolNames: [] }]]), + }, + config: { model: id }, + facades: { resolveModelRuntime }, + }); + try { + const events = await Array.fromAsync( + await preparedStream( + f, + executorAgentJson({ + agentId: "coder", + ...("thinking" in scenario ? { thinking: scenario.thinking } : {}), + ...("completion" in scenario ? { maxOutputTokens: scenario.completion } : {}), + }, "EXECUTOR_AGENT_INPUT_TOO_LARGE"), + ), + ); + assertEquals(provider.callCount, 1); + assertEquals(provider.calls[0]?.maxOutputTokens, scenario.expected); + assert( + events.some((event) => + event !== null && typeof event === "object" && !Array.isArray(event) && + event.type === "complete" + ), + ); + } finally { + await f.owner.close(); + broker.close(); + executor.close(); + await Promise.all([broker.settled, executor.settled]); + } + }); + } + it("keeps skill references and scripts outside a loader-only grant after loading a skill", async () => { const visible: string[][] = []; const f = fixture({ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 75402c8396..8720b2b832 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -5,7 +5,13 @@ import { resolvePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; -import { VERYFRONT_CLOUD_MODEL_PREFIX } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; +import { + resolveVeryfrontCloudModelThinking, + resolveVeryfrontCloudReasoningOption, + resolveVeryfrontCloudThinkingProviderOptions, + VERYFRONT_CLOUD_MODEL_PREFIX, +} from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; +import { executorAdditiveReasoningTokens } from "./executor-model-grant.ts"; import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; import { isToolVisibleTo } from "#veryfront/tool"; import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; @@ -434,7 +440,20 @@ export function createExecutorRuntimePreparation(input: Options) { ); // The first facade call can reserve resources before throwing. resourcesStarted = true; - resolveModelRuntime(modelId); + const modelRuntime = resolveModelRuntime(modelId)!; + const thinking = request.thinking ?? definition.thinking ?? + resolveVeryfrontCloudModelThinking(modelId); + const reasoningBudget = executorAdditiveReasoningTokens(modelRuntime, { + reasoning: resolveVeryfrontCloudReasoningOption(modelId, thinking), + providerOptions: resolveVeryfrontCloudThinkingProviderOptions(modelId, thinking), + }); + const completionAllowance = modelGrant.maxOutputTokens - reasoningBudget; + if ( + completionAllowance <= 0 || + (request.maxOutputTokens ?? completionAllowance) > completionAllowance + ) { + refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + } const execution = grant.execution; const steering = facades.projectSteering ? await facades.projectSteering.prepare({ @@ -498,13 +517,13 @@ export function createExecutorRuntimePreparation(input: Options) { }) : definition.system ?? definition.instructions), temperature: request.temperature ?? definition.temperature, - thinking: request.thinking ?? definition.thinking, + thinking, maxSteps: mathMin( request.maxSteps ?? grant.maxSteps, definition.maxSteps ?? grant.maxSteps, grant.maxSteps, ), - maxOutputTokens: request.maxOutputTokens ?? modelGrant.maxOutputTokens, + maxOutputTokens: request.maxOutputTokens ?? completionAllowance, allowedTools: allowedToolNames, allowedProviderTools: providerToolNames, availableSkillIds: skills.allowedSkillIds, From 93caecf9365eba12616da2f73f7fe1c554ba9548 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 22:50:58 +0200 Subject: [PATCH 172/194] fix(agent): traverse private model tool calls by index --- src/agent/runtime/index.ts | 17 +- .../terminal-tool-call-iteration.test.ts | 81 ++++++++ .../tool-search-private-intrinsics.test.ts | 193 +++++++++--------- 3 files changed, 193 insertions(+), 98 deletions(-) create mode 100644 tests/integration/agent/terminal-tool-call-iteration.test.ts diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index f37376716e..467e1eeb70 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -1113,7 +1113,10 @@ function buildGeneratedAssistantMessage( ): Message { const parts: MessagePart[] = []; if (response.text) pushPrivateArray(parts, { type: "text", text: response.text }); - for (const toolCall of response.toolCalls ?? []) { + const responseToolCalls = response.toolCalls ?? []; + for (let index = 0; index < responseToolCalls.length; index++) { + if (!ObjectHasOwn(responseToolCalls, index)) continue; + const toolCall = responseToolCalls[index]!; pushPrivateArray(parts, { type: `tool-${toolCall.toolName}`, toolCallId: toolCall.toolCallId, @@ -3041,7 +3044,9 @@ export class AgentRuntime { this.status = "tool_execution"; addSpanEvent(loopSpan, "tool_execution_start", { count: response.toolCalls.length }); - for (const tc of response.toolCalls) { + for (let toolCallIndex = 0; toolCallIndex < response.toolCalls.length; toolCallIndex++) { + if (!ObjectHasOwn(response.toolCalls, toolCallIndex)) continue; + const tc = response.toolCalls[toolCallIndex]!; throwIfAborted(abortSignal); const toolCall: ToolCall = { id: tc.toolCallId, @@ -4041,7 +4046,9 @@ export class AgentRuntime { for (const toolResult of finalToolResults.values()) { await persistToolResult(toolResult); } - for (const toolCall of streamedToolCalls) { + for (let toolCallIndex = 0; toolCallIndex < streamedToolCalls.length; toolCallIndex++) { + if (!ObjectHasOwn(streamedToolCalls, toolCallIndex)) continue; + const toolCall = streamedToolCalls[toolCallIndex]!; // Terminal. Every incomplete local call recorded here is also // terminalized into history, so announce unconditionally and let the // wire carry the same failure. `recordIncompleteLocalToolError` @@ -4067,7 +4074,9 @@ export class AgentRuntime { : undefined; } - for (const tc of streamedToolCalls) { + for (let toolCallIndex = 0; toolCallIndex < streamedToolCalls.length; toolCallIndex++) { + if (!ObjectHasOwn(streamedToolCalls, toolCallIndex)) continue; + const tc = streamedToolCalls[toolCallIndex]!; throwIfAborted(abortSignal); if (shouldRecoverInterruptedLocalToolBatch && tc.providerExecuted !== true) { if (await recordIncompleteLocalToolError(tc, { includeInResponse: false })) { diff --git a/tests/integration/agent/terminal-tool-call-iteration.test.ts b/tests/integration/agent/terminal-tool-call-iteration.test.ts new file mode 100644 index 0000000000..fdd9acad60 --- /dev/null +++ b/tests/integration/agent/terminal-tool-call-iteration.test.ts @@ -0,0 +1,81 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { tool } from "#veryfront/tool"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private terminal tool calls ${hooks ? "hooks" : "baseline"}`, () => { + it("finishes a provider-executed tool step without iterating private materialized arguments", async () => { + const marker = "synthetic-private-terminal-arguments"; + const model = scriptedModel([{ + parts: [ + { + type: "tool-call", + toolCallId: "terminal", + toolName: "inspect", + input: { query: marker }, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "terminal", + toolName: "inspect", + output: { ok: true }, + providerExecuted: true, + }, + { type: "text-delta", text: "Terminal complete" }, + { type: "finish", finishReason: "stop" }, + ], + }], { only: "stream" }); + let localExecutions = 0; + const runtime = createEphemeralAgentWithRuntimeOptions({ + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + skills: false, + maxSteps: 1, + tools: { + inspect: tool({ + id: "inspect", + description: "Synthetic inspection", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: () => { + localExecutions++; + return { ok: true }; + }, + }), + }, + }, { resolveModelRuntime: () => model }); + const iterator = Array.prototype[Symbol.iterator]; + const hasOwn = Object.hasOwn; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let output = ""; + try { + if (hooks) { + Array.prototype[Symbol.iterator] = function () { + for (let index = 0; index < this.length; index++) { + const value = this[index]; + if ( + value && typeof value === "object" && hasOwn(value, "arguments") && + typeof value.arguments === "string" && apply(includes, value.arguments, [marker]) + ) observations++; + } + return apply(iterator, this, []); + }; + } + output = await (await runtime.stream({ input: "Complete provider work" })) + .toDataStreamResponse().text(); + } finally { + if (hooks) Array.prototype[Symbol.iterator] = iterator; + } + assertStringIncludes(output, "Terminal complete"); + assertEquals(model.callCount, 1); + assertEquals(localExecutions, 0); + assertEquals(observations, 0); + }); + }); +} diff --git a/tests/integration/agent/tool-search-private-intrinsics.test.ts b/tests/integration/agent/tool-search-private-intrinsics.test.ts index 38198d413c..8404314ee5 100644 --- a/tests/integration/agent/tool-search-private-intrinsics.test.ts +++ b/tests/integration/agent/tool-search-private-intrinsics.test.ts @@ -11,103 +11,108 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; for (const probe of ["baseline", "strings", "arrays"]) { describe(`private framework tool search ${probe}`, () => { - it("loads and executes a matching tool while preserving structured cache metadata", async () => { - const marker = "synthetic private search"; - const model = scriptedModel([ - { toolCalls: [{ id: "search", name: "tool_search", input: { query: ` ${marker} ` } }] }, - { toolCalls: [{ id: "lookup", name: "lookup_private", input: {} }] }, - { text: "Complete" }, - ], { only: "stream" }); - let executions = 0; - const config: AgentConfig & RuntimeToolFilterConfig = { - model: "veryfront-cloud/openai/gpt-5.4", - system: "Synthetic instructions", - skills: false, - maxSteps: 3, - __vfToolLoadingMode: "deferred", - tools: { - lookup_private: tool({ - id: "lookup_private", - // The query's complete marker must not also appear in authored tool metadata. - description: "Search private synthetic records", - inputSchema: defineSchema((v) => v.object({}))(), - execute: () => { - executions++; - return { ok: true }; - }, - }), - }, - }; - const runtime = createEphemeralAgentWithRuntimeOptions(config, { - resolveModelRuntime: () => model, - }); - const contextInput = { - instructions: [{ - role: "system" as const, - content: "Instructions", - providerOptions: { anthropic: { cacheControl: { type: "ephemeral" }, custom: marker } }, - }], - }; - const expectedContext = buildAgentCallContext(contextInput); - const stringify = JSON.stringify; - const includes = String.prototype.includes; - const apply = Reflect.apply; - const defineProperty = Object.defineProperty; - const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = []; - let observations = 0; - const observationSites: string[] = []; - const replace = (target: object, key: PropertyKey) => { - const descriptor = Object.getOwnPropertyDescriptor(target, key)!; - originals.push({ target, key, descriptor }); - defineProperty(target, key, { - ...descriptor, - value: function (this: unknown, ...args: unknown[]) { - if (apply(includes, stringify(this) ?? "", [marker])) { - observations++; - if (observationSites.length < 5) { - observationSites.push(`${String(key)}: ${new Error().stack}`); - } - } - return apply(descriptor.value, this, args); + for (const mode of ["generate", "stream"] as const) { + it(`loads and executes a matching tool in ${mode} while preserving structured cache metadata`, async () => { + const marker = "synthetic private search"; + const model = scriptedModel([ + { toolCalls: [{ id: "search", name: "tool_search", input: { query: ` ${marker} ` } }] }, + { toolCalls: [{ id: "lookup", name: "lookup_private", input: {} }] }, + { text: "Complete" }, + ], { only: mode }); + let executions = 0; + const config: AgentConfig & RuntimeToolFilterConfig = { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + skills: false, + maxSteps: 3, + __vfToolLoadingMode: "deferred", + tools: { + lookup_private: tool({ + id: "lookup_private", + // The query's complete marker must not also appear in authored tool metadata. + description: "Search private synthetic records", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + executions++; + return { ok: true }; + }, + }), }, + }; + const runtime = createEphemeralAgentWithRuntimeOptions(config, { + resolveModelRuntime: () => model, }); - }; - let context; - let output = ""; - try { - if (probe === "strings") { - for ( - const key of [ - "trim", - "toLowerCase", - "replace", - "replaceAll", - "indexOf", - "slice", - "split", - "includes", - ] - ) replace(String.prototype, key); - } - if (probe === "arrays") { - for (const key of ["some", "map", "filter", "reduce", "sort", Symbol.iterator]) { - replace(Array.prototype, key); + const contextInput = { + instructions: [{ + role: "system" as const, + content: "Instructions", + providerOptions: { anthropic: { cacheControl: { type: "ephemeral" }, custom: marker } }, + }], + }; + const expectedContext = buildAgentCallContext(contextInput); + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = + []; + let observations = 0; + const observationSites: string[] = []; + const replace = (target: object, key: PropertyKey) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (apply(includes, stringify(this) ?? "", [marker])) { + observations++; + if (observationSites.length < 5) { + observationSites.push(`${String(key)}: ${new Error().stack}`); + } + } + return apply(descriptor.value, this, args); + }, + }); + }; + let context; + let output = ""; + try { + if (probe === "strings") { + for ( + const key of [ + "trim", + "toLowerCase", + "replace", + "replaceAll", + "indexOf", + "slice", + "split", + "includes", + ] + ) replace(String.prototype, key); + } + if (probe === "arrays") { + for (const key of ["some", "map", "filter", "reduce", "sort", Symbol.iterator]) { + replace(Array.prototype, key); + } + } + context = buildAgentCallContext(contextInput); + output = mode === "stream" + ? await (await runtime.stream({ input: "Find and use the lookup tool" })) + .toDataStreamResponse().text() + : (await runtime.generate({ input: "Find and use the lookup tool" })).text; + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); } } - context = buildAgentCallContext(contextInput); - output = await (await runtime.stream({ input: "Find and use the lookup tool" })) - .toDataStreamResponse().text(); - } finally { - for (let index = originals.length - 1; index >= 0; index--) { - const original = originals[index]!; - defineProperty(original.target, original.key, original.descriptor); - } - } - assertEquals(context, expectedContext); - assertEquals(executions, 1); - assertEquals(model.callCount, 3); - assertStringIncludes(output, "Complete"); - assertEquals(observations, 0, observationSites.join("\n")); - }); + assertEquals(context, expectedContext); + assertEquals(executions, 1); + assertEquals(model.callCount, 3); + assertStringIncludes(output, "Complete"); + assertEquals(observations, 0, observationSites.join("\n")); + }); + } }); } From 7ea1f954ba376b34e282f81f712fd09cd7fc20fc Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:02:04 +0200 Subject: [PATCH 173/194] fix(runtime): protect model request and result array conversions --- .../runtime/model-runtime.test-helpers.ts | 25 +++-- src/runtime/runtime-bridge.test.ts | 21 ++++ src/runtime/runtime-bridge.ts | 104 +++++++++++------- 3 files changed, 101 insertions(+), 49 deletions(-) diff --git a/src/agent/runtime/model-runtime.test-helpers.ts b/src/agent/runtime/model-runtime.test-helpers.ts index 665c13d157..f72bb5dbf1 100644 --- a/src/agent/runtime/model-runtime.test-helpers.ts +++ b/src/agent/runtime/model-runtime.test-helpers.ts @@ -12,6 +12,10 @@ import type { ModelRuntime, ModelRuntimeCallOptions } from "#veryfront/provider/types.ts"; import type { RuntimeStreamPart } from "#veryfront/agent/runtime/runtime-tool-types.ts"; import { compareStrings } from "#veryfront/utils/compare.ts"; +import { mapPrivateArray, pushPrivateArray } from "#veryfront/security/private-array.ts"; + +// Model fakes represent the trusted provider boundary during intrinsic-hook regressions. +const stringifyProviderFixture = JSON.stringify; export type ScriptedUsage = NonNullable< Extract["totalUsage"] @@ -80,7 +84,7 @@ export interface ScriptedModel extends ModelRuntime { export function runtimeStream(parts: readonly RuntimeStreamPart[]): ReadableStream { return new ReadableStream({ start(controller) { - for (const part of parts) controller.enqueue(part); + for (let index = 0; index < parts.length; index++) controller.enqueue(parts[index]); controller.close(); }, }); @@ -120,7 +124,7 @@ function pendingStream( ): ReadableStream { return new ReadableStream({ start(controller) { - for (const part of parts) controller.enqueue(part); + for (let index = 0; index < parts.length; index++) controller.enqueue(parts[index]); if (!abortSignal) return; if (abortSignal.aborted) { controller.error(abortSignal.reason); @@ -145,7 +149,7 @@ export function scriptedModel( const nextTurn = (callOptions: ModelRuntimeCallOptions): ScriptedTurn => { const call = calls.length; - calls.push(callOptions); + pushPrivateArray(calls, callOptions); const scripted = turns[call] ?? (options.repeatLastTurn ? turns.at(-1) : undefined); if (scripted === undefined) { throw new Error( @@ -207,7 +211,7 @@ export function scriptedModel( : { providerMetadata: turn.providerMetadata }; if ("content" in turn) { return Promise.resolve({ - content: [...turn.content], + content: mapPrivateArray(turn.content, (part) => part), finishReason: turn.finishReason ?? "stop", usage, ...metadata, @@ -222,11 +226,11 @@ export function scriptedModel( }); } return Promise.resolve({ - content: turn.toolCalls.map((call) => ({ + content: mapPrivateArray(turn.toolCalls, (call) => ({ type: "tool-call", toolCallId: call.id, toolName: call.name, - input: typeof call.input === "string" ? call.input : JSON.stringify(call.input), + input: typeof call.input === "string" ? call.input : stringifyProviderFixture(call.input), })), finishReason: turn.finishReason ?? "tool-calls", usage, @@ -261,11 +265,12 @@ export function scriptedModel( const parts: RuntimeStreamPart[] = []; let finishReason: string; if ("text" in turn) { - parts.push({ type: "text-delta", text: turn.text }); + pushPrivateArray(parts, { type: "text-delta", text: turn.text }); finishReason = turn.finishReason ?? "stop"; } else { - for (const call of turn.toolCalls) { - parts.push({ + for (let index = 0; index < turn.toolCalls.length; index++) { + const call = turn.toolCalls[index]!; + pushPrivateArray(parts, { type: "tool-call", toolCallId: call.id, toolName: call.name, @@ -274,7 +279,7 @@ export function scriptedModel( } finishReason = turn.finishReason ?? "tool-calls"; } - parts.push({ + pushPrivateArray(parts, { type: "finish", finishReason, totalUsage: usage, diff --git a/src/runtime/runtime-bridge.test.ts b/src/runtime/runtime-bridge.test.ts index 246134211f..fed71e6145 100644 --- a/src/runtime/runtime-bridge.test.ts +++ b/src/runtime/runtime-bridge.test.ts @@ -30,6 +30,27 @@ function readableStreamFrom(values: Iterable): ReadableStream { } describe("runtime-bridge", () => { + it("copies request and result arrays without consulting their own iterators", async () => { + let reads = 0; + const messages = [{ role: "user" as const, content: "synthetic private request" }]; + const content = [{ type: "text" as const, text: "synthetic private result" }]; + for (const value of [messages, content]) { + Object.defineProperty(value, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + } + const model = createGenerateModel("test", "test/private-arrays", async () => ({ + content, + finishReason: "stop", + usage: {}, + })); + const result = await generateText({ model, messages }); + assertEquals(result.text, "synthetic private result"); + assertEquals(reads, 0); + }); it("preserves structured system messages and cache metadata at model dispatch", async () => { let capturedPrompt: unknown; const model = createGenerateModel("test", "test/layered-system", async (options) => { diff --git a/src/runtime/runtime-bridge.ts b/src/runtime/runtime-bridge.ts index d405ed13d3..fa6406aba7 100644 --- a/src/runtime/runtime-bridge.ts +++ b/src/runtime/runtime-bridge.ts @@ -1,3 +1,5 @@ +import { mapPrivateArray, pushPrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; /** * Runtime Bridge @@ -48,6 +50,8 @@ const cloneStructuredValue = globalThis.structuredClone; const ObjectDefineProperty = Object.defineProperty; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const ObjectHasOwn = Object.hasOwn; +const ArrayIsArray = Array.isArray; +const ObjectEntries = Object.entries; const ReflectApply = Reflect.apply; const ReflectOwnKeys = Reflect.ownKeys; const logger = serverLogger.component("runtime-bridge"); @@ -202,7 +206,7 @@ function readSystemProviderOptions( const providerOptions = descriptor.value; return providerOptions && typeof providerOptions === "object" && - !Array.isArray(providerOptions) + !ArrayIsArray(providerOptions) ? providerOptions as Record : undefined; } @@ -249,14 +253,16 @@ function normalizeSystemMessages(system: GenerateTextOptions["system"]): ChatSys }]; } - if (Array.isArray(system)) { + if (ArrayIsArray(system)) { const messages: ChatSystemMessage[] = []; - for (const entry of system) { + for (let entryIndex = 0; entryIndex < system.length; entryIndex++) { + if (!ObjectHasOwn(system, entryIndex)) continue; + const entry = system[entryIndex]!; if (!entry || typeof entry !== "object") continue; const entryContent = readSystemContent(entry); if (entryContent === undefined) continue; const providerOptions = readSystemProviderOptions(entry); - messages.push({ + pushPrivateArray(messages, { role: "system", content: entryContent, ...(providerOptions ? { providerOptions } : {}), @@ -271,10 +277,12 @@ function normalizeSystemMessages(system: GenerateTextOptions["system"]): ChatSys function getProviderRequestMessages( messages: TextGenerationRuntimeMessage[], ): TextGenerationRuntimeMessage[] { - const requestMessages = [...messages]; + const requestMessages = mapPrivateArray(messages, (message) => message); - while (requestMessages.at(-1)?.role === "assistant") { - requestMessages.pop(); + while ( + requestMessages.length > 0 && requestMessages[requestMessages.length - 1]?.role === "assistant" + ) { + requestMessages.length--; } return requestMessages; @@ -284,19 +292,21 @@ function toRuntimePrompt( system: readonly ChatSystemMessage[], messages: TextGenerationRuntimeMessage[], ): DirectModelMessage[] { - const prompt: DirectModelMessage[] = system.map((message) => ({ + const prompt: DirectModelMessage[] = mapPrivateArray(system, (message) => ({ role: "system", content: message.content, ...(message.providerOptions === undefined ? {} : { providerOptions: message.providerOptions }), })); - for (const message of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + if (!ObjectHasOwn(messages, messageIndex)) continue; + const message = messages[messageIndex]!; switch (message.role) { case "system": - prompt.push({ role: "system", content: message.content }); + pushPrivateArray(prompt, { role: "system", content: message.content }); break; case "user": - prompt.push({ + pushPrivateArray(prompt, { role: "user", content: typeof message.content === "string" ? [{ type: "text", text: message.content }] @@ -304,15 +314,17 @@ function toRuntimePrompt( }); break; case "assistant": - prompt.push({ + pushPrivateArray(prompt, { role: "assistant", - content: message.content.map((part) => - part.type === "text" ? { type: "text" as const, text: part.text } : { - type: "tool-call" as const, - toolCallId: part.toolCallId, - toolName: part.toolName, - input: part.input, - } + content: mapPrivateArray( + message.content, + (part) => + part.type === "text" ? { type: "text" as const, text: part.text } : { + type: "tool-call" as const, + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input, + }, ), ...(message.providerMetadata === undefined ? {} @@ -320,9 +332,9 @@ function toRuntimePrompt( }); break; case "tool": - prompt.push({ + pushPrivateArray(prompt, { role: "tool", - content: message.content.map((part) => ({ + content: mapPrivateArray(message.content, (part) => ({ type: "tool-result" as const, toolCallId: part.toolCallId, toolName: part.toolName, @@ -360,7 +372,7 @@ function readOwnEnumerableDataDescriptor( } function sanitizePersistedCacheControl(value: unknown): PersistedCacheControl | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!value || typeof value !== "object" || ArrayIsArray(value)) { return undefined; } const type = readOwnEnumerableDataDescriptor(value, "type"); @@ -380,7 +392,7 @@ function sanitizePersistedCacheControl(value: unknown): PersistedCacheControl | function sanitizePersistedProviderOptions( value: unknown, ): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!value || typeof value !== "object" || ArrayIsArray(value)) { return undefined; } @@ -392,12 +404,14 @@ function sanitizePersistedProviderOptions( } const sanitized: Record = {}; let retained = false; - for (const key of keys) { + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + if (!ObjectHasOwn(keys, keyIndex)) continue; + const key = keys[keyIndex]!; if (typeof key !== "string" || key.length === 0) { continue; } const providerBucket = readOwnEnumerableDataDescriptor(value, key)?.value; - if (!providerBucket || typeof providerBucket !== "object" || Array.isArray(providerBucket)) { + if (!providerBucket || typeof providerBucket !== "object" || ArrayIsArray(providerBucket)) { continue; } const cacheControl = sanitizePersistedCacheControl( @@ -420,7 +434,7 @@ function sanitizePersistedProviderOptions( function sanitizeModelCallContextMessages( messages: readonly DirectModelMessage[], ): ModelCallMessage[] { - return messages.map((message) => { + return mapPrivateArray(messages, (message) => { if (message.role === "assistant") { return { role: "assistant", content: message.content }; } @@ -606,7 +620,7 @@ function isRuntimeProviderToolDefinition( "args" in value && typeof value.args === "object" && value.args !== null && - !Array.isArray(value.args); + !ArrayIsArray(value.args); } function isRuntimeFunctionToolDefinition( @@ -634,9 +648,12 @@ async function resolveDirectTools( const resolvedTools: ModelCallTool[] = []; - for (const [name, definition] of Object.entries(tools)) { + const entries = ObjectEntries(tools); + for (let index = 0; index < entries.length; index++) { + const name = entries[index]![0]; + const definition = entries[index]![1]; if (isRuntimeProviderToolDefinition(definition)) { - resolvedTools.push({ + pushPrivateArray(resolvedTools, { type: "provider", name, id: definition.id, @@ -650,7 +667,7 @@ async function resolveDirectTools( } const inputSchema = await Promise.resolve(definition.inputSchema.jsonSchema); - resolvedTools.push({ + pushPrivateArray(resolvedTools, { type: "function", name, ...(typeof definition.description === "string" @@ -826,14 +843,17 @@ function buildDirectGenerateResult( const toolCalls: RuntimeGenerateTextResult["toolCalls"] = []; const toolResults: RuntimeGenerateTextResult["toolResults"] = []; - for (const part of result.content ?? []) { + const content = result.content ?? []; + for (let partIndex = 0; partIndex < content.length; partIndex++) { + if (!ObjectHasOwn(content, partIndex)) continue; + const part = content[partIndex]!; if (isDirectTextPart(part)) { text += part.text; continue; } if (isDirectToolCallPart(part)) { - toolCalls.push({ + pushPrivateArray(toolCalls, { toolCallId: part.toolCallId, toolName: part.toolName, input: parseToolCallInput(part.input), @@ -841,7 +861,7 @@ function buildDirectGenerateResult( } if (isDirectToolResultPart(part)) { - toolResults.push({ + pushPrivateArray(toolResults, { toolCallId: part.toolCallId, toolName: part.toolName, result: part.result, @@ -935,8 +955,14 @@ async function buildGenerateResultFromStream( let usage: RuntimeGenerateTextResult["usage"]; let finishReason: string | null = null; let providerMetadata: Record | undefined; - const toolCalls = new Map[number]>(); - const toolInputs = new Map(); + const toolCalls = createPrivateMap< + string, + NonNullable[number] + >(); + const toolInputs = createPrivateMap< + string, + { toolCallId: string; toolName: string; input: string } + >(); const toolResults: NonNullable = []; for await (const rawPart of getPrivateAsyncIterator(mapReadableStream(stream))) { @@ -1001,7 +1027,7 @@ async function buildGenerateResultFromStream( case "tool-result": { const result = part.result ?? part.output ?? part.error; - toolResults.push({ + pushPrivateArray(toolResults, { toolCallId: part.toolCallId, toolName: part.toolName, result, @@ -1012,7 +1038,7 @@ async function buildGenerateResultFromStream( } case "tool-error": - toolResults.push({ + pushPrivateArray(toolResults, { toolCallId: part.toolCallId, toolName: part.toolName, result: part.error, @@ -1318,12 +1344,12 @@ function assertValidEmbeddingVectors( value: unknown, expectedCount: number, ): asserts value is number[][] { - if (!Array.isArray(value) || value.length !== expectedCount) { + if (!ArrayIsArray(value) || value.length !== expectedCount) { throw new TypeError("Embedding runtime returned invalid vectors"); } let dimension: number | undefined; for (const vector of value) { - if (!Array.isArray(vector) || vector.length === 0) { + if (!ArrayIsArray(vector) || vector.length === 0) { throw new TypeError("Embedding runtime returned invalid vectors"); } if (dimension === undefined) dimension = vector.length; From e707b6eedc5c0f8bd1715678e6190cf3c6044909 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:04:21 +0200 Subject: [PATCH 174/194] fix(agent): emit private buffered deltas through indexed reads --- src/agent/runtime/chat-stream-handler.test.ts | 21 ++++++++++++ src/agent/runtime/chat-stream-handler.ts | 10 ++++-- .../terminal-tool-call-iteration.test.ts | 33 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/agent/runtime/chat-stream-handler.test.ts b/src/agent/runtime/chat-stream-handler.test.ts index 891ca4b594..386179af2a 100644 --- a/src/agent/runtime/chat-stream-handler.test.ts +++ b/src/agent/runtime/chat-stream-handler.test.ts @@ -11,6 +11,7 @@ import { } from "#veryfront/observability/tracing/api-shim.ts"; import { createMockResult, createSSECollector } from "./chat-stream-handler.test-helpers.ts"; import { + announceStreamedToolCallInput, createRuntimeStreamSource, createStreamState, processStream, @@ -71,6 +72,26 @@ function pendingAsyncIterable() { } describe("chat-stream-handler", () => { + it("announces buffered deltas once without consulting the buffer iterator", () => { + const { controller, encoder, events } = createSSECollector(); + const inputDeltas = ['{"query":', '"synthetic private input"}']; + let reads = 0; + Object.defineProperty(inputDeltas, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + const toolCall = { id: "call", name: "inspect", arguments: inputDeltas.join(""), inputDeltas }; + announceStreamedToolCallInput(controller, encoder, toolCall); + announceStreamedToolCallInput(controller, encoder, toolCall); + assertEquals(events, [ + { type: "tool-input-start", toolCallId: "call", toolName: "inspect" }, + { type: "tool-input-delta", toolCallId: "call", inputTextDelta: inputDeltas[0] }, + { type: "tool-input-delta", toolCallId: "call", inputTextDelta: inputDeltas[1] }, + ]); + assertEquals(reads, 0); + }); describe("summarizeProviderToolDebugValue", () => { it("redacts sensitive provider tool debug fields", () => { assertEquals( diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 38da71852f..9f219f33e5 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -285,7 +285,10 @@ export function announceStreamedToolCallInput( ...(dynamic ? { dynamic: true } : {}), }); - for (const delta of toolCall.inputDeltas ?? []) { + const deltas = toolCall.inputDeltas ?? []; + for (let index = 0; index < deltas.length; index++) { + if (!hasOwn(deltas, index)) continue; + const delta = deltas[index]!; sendSSE(controller, encoder, { type: "tool-input-delta", toolCallId: toolCall.id, @@ -726,8 +729,9 @@ async function processActiveStream( if (frame.class === "semantic" && frame.event.type === "usage") { callbacks?.onUsage?.(toLegacyRuntimeUsage(frame.event.usage)); } - for (const event of live.encode(frame)) { - sendSSE(controller, encoder, event); + const events = live.encode(frame); + for (let index = 0; index < events.length; index++) { + if (hasOwn(events, index)) sendSSE(controller, encoder, events[index]!); } } } catch (error) { diff --git a/tests/integration/agent/terminal-tool-call-iteration.test.ts b/tests/integration/agent/terminal-tool-call-iteration.test.ts index fdd9acad60..9ac2a060e5 100644 --- a/tests/integration/agent/terminal-tool-call-iteration.test.ts +++ b/tests/integration/agent/terminal-tool-call-iteration.test.ts @@ -1,6 +1,8 @@ import "#veryfront/schemas/_test-setup.ts"; import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { announceStreamedToolCallInput } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { createSSECollector } from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; import { tool } from "#veryfront/tool"; import { defineSchema } from "#veryfront/schemas/index.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; @@ -8,6 +10,37 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; for (const hooks of [false, true]) { describe(`private terminal tool calls ${hooks ? "hooks" : "baseline"}`, () => { + it("announces private buffered input deltas without invoking their shared iterator", () => { + const inputDeltas = ['{"query":', '"synthetic private deltas"}']; + const call = { + id: "buffered", + name: "inspect", + arguments: inputDeltas.join(""), + inputDeltas, + }; + const { controller, encoder, events } = createSSECollector(); + const iterator = Array.prototype[Symbol.iterator]; + const apply = Reflect.apply; + let observations = 0; + try { + if (hooks) { + Array.prototype[Symbol.iterator] = function () { + if (this === inputDeltas) observations++; + return apply(iterator, this, []); + }; + } + announceStreamedToolCallInput(controller, encoder, call); + announceStreamedToolCallInput(controller, encoder, call); + } finally { + if (hooks) Array.prototype[Symbol.iterator] = iterator; + } + assertEquals(events, [ + { type: "tool-input-start", toolCallId: "buffered", toolName: "inspect" }, + { type: "tool-input-delta", toolCallId: "buffered", inputTextDelta: inputDeltas[0] }, + { type: "tool-input-delta", toolCallId: "buffered", inputTextDelta: inputDeltas[1] }, + ]); + assertEquals(observations, 0); + }); it("finishes a provider-executed tool step without iterating private materialized arguments", async () => { const marker = "synthetic-private-terminal-arguments"; const model = scriptedModel([{ From d3e9a1bb85e3c0c83e5fed2c01a135d041cd4a25 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:04:55 +0200 Subject: [PATCH 175/194] fix(agent): reject embedded broker credentials at ingress --- docs/guides/agent-service-runtime.md | 2 + src/agent/service/broker-ingress.test.ts | 57 ++++++++++++++++++++++++ src/agent/service/broker-ingress.ts | 7 ++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index b06058ea21..8764d95438 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -452,6 +452,8 @@ control-plane HTTP adapter. The broker installs invocation grants, describes the selected agent, and prepares the executor before accepting a run. It keeps model and tool execution unavailable during preparation. Configure detached 202 responses or request-owned SSE responses in trusted service configuration. +Signed invocations reject application strings or property names containing a known +broker credential, including credentials embedded in messages or attachment URLs. Detached runs require output persistence callbacks; their finalization remains part of the session's owned work until all writes settle. diff --git a/src/agent/service/broker-ingress.test.ts b/src/agent/service/broker-ingress.test.ts index 5e8bd61e23..5395cf2283 100644 --- a/src/agent/service/broker-ingress.test.ts +++ b/src/agent/service/broker-ingress.test.ts @@ -103,6 +103,63 @@ describe("managed broker ingress", () => { assertEquals(signed.request.bodyUsed, true); }); + for ( + const credential of [ + "api-auth-token", + "inference-token", + "run-event-token", + "Bearer broker-token", + "broker-token", + ] + ) { + it(`rejects embedded ${credential} in application messages`, async () => { + const signed = await signedRequest(invocation({ + messages: [{ + id: "message-1", + role: "user", + content: `Use ${credential} for this request`, + }], + })); + await assertIngressError( + () => parseBrokerRuntimeAgentIngress(signed.request, options(signed.publicKeyPem)), + 403, + "BROKER_INGRESS_SCOPE_DENIED", + ); + }); + } + + it("rejects credentials inside nested attachment URLs and property names", async () => { + for ( + const forwardedProps of [ + { attachments: [{ url: "https://files.test/document?token=api-auth-token&download=1" }] }, + { attachments: [{ "result-inference-token-metadata": "value" }] }, + ] + ) { + const signed = await signedRequest(invocation({ forwardedProps })); + await assertIngressError( + () => parseBrokerRuntimeAgentIngress(signed.request, options(signed.publicKeyPem)), + 403, + "BROKER_INGRESS_SCOPE_DENIED", + ); + } + }); + + it("preserves application strings that do not contain a credential", async () => { + const messages = [{ + id: "message-1", + role: "user" as const, + content: "Explain Bearer authentication", + }]; + const forwardedProps = { attachments: [{ url: "https://files.test/document?download=1" }] }; + const signed = await signedRequest(invocation({ messages, forwardedProps })); + const result = await parseBrokerRuntimeAgentIngress( + signed.request, + options(signed.publicKeyPem), + ); + assertEquals(result.executor.input.messages, messages); + assertEquals(result.executor.input.forwardedProps, forwardedProps); + }); + it("rejects invalid signatures and signed method, path, or run mismatches", async () => { const signed = await signedRequest(); const invalid = new Request(signed.request.url, { diff --git a/src/agent/service/broker-ingress.ts b/src/agent/service/broker-ingress.ts index 9c3e87cb2f..6a55f3c7ca 100644 --- a/src/agent/service/broker-ingress.ts +++ b/src/agent/service/broker-ingress.ts @@ -261,6 +261,7 @@ export async function parseBrokerRuntimeAgentIngress( for ( const token of [ inboundAuthorization, + /^Bearer\s+(.+)$/i.exec(inboundAuthorization)?.[1], apiAuthToken, runEventToken, invocation.credentials?.inferenceAuthToken, @@ -305,9 +306,11 @@ function containsForwardedAuthority(value: unknown): boolean { } function containsString(value: unknown, expected: string): boolean { - if (value === expected) return true; + if (typeof value === "string") return value.includes(expected); if (!value || typeof value !== "object") return false; return Array.isArray(value) ? value.some((entry) => containsString(entry, expected)) - : Object.values(value).some((entry) => containsString(entry, expected)); + : Object.entries(value).some(([key, entry]) => + key.includes(expected) || containsString(entry, expected) + ); } From e6ef05468170d5df1ed02d9d097a8792dbf69df8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:17:18 +0200 Subject: [PATCH 176/194] fix(agent): map split provider replay groups privately --- ...xt-generation-runtime-message-converter.ts | 2 +- .../split-replay-mapping-intrinsics.test.ts | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/integration/agent/split-replay-mapping-intrinsics.test.ts diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index a9f973bfe3..3b40c69c88 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -619,7 +619,7 @@ function splitAnthropicProviderMetadata( anthropic.rawAssistantMessages, segmentCount, ); - return grouped?.map((rawAssistantMessages) => ({ + return grouped && mapPrivateArray(grouped, (rawAssistantMessages) => ({ ...providerMetadata, anthropic: { ...anthropic, rawAssistantMessages }, })); diff --git a/tests/integration/agent/split-replay-mapping-intrinsics.test.ts b/tests/integration/agent/split-replay-mapping-intrinsics.test.ts new file mode 100644 index 0000000000..9a21fdfaca --- /dev/null +++ b/tests/integration/agent/split-replay-mapping-intrinsics.test.ts @@ -0,0 +1,60 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { convertToTextGenerationRuntimeMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; +import { + attachProviderMetadata, + markProviderReplayDelivered, +} from "#veryfront/agent/runtime/provider-metadata.ts"; +import type { Message } from "#veryfront/agent/types.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private split replay mapping ${hooks ? "hooks" : "baseline"}`, () => { + it("distributes raw replay groups without passing them through Array.map", () => { + const marker = "synthetic-private-split-replay"; + const rawToolUse = { + type: "tool_use", + id: "call", + name: "inspect", + input: { query: marker }, + }; + const rawText = { type: "text", text: marker }; + const message: Message = { + id: "assistant", + role: "assistant", + parts: [ + { type: "tool-call", toolCallId: "call", toolName: "inspect", args: { query: marker } }, + { type: "tool-result", toolCallId: "call", toolName: "inspect", result: { ok: true } }, + { type: "text", text: marker }, + ], + }; + markProviderReplayDelivered(attachProviderMetadata(message, { + anthropic: { rawAssistantMessages: [[rawToolUse], [rawText]] }, + })); + const map = Array.prototype.map; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let converted: ReturnType = []; + try { + if (hooks) { + Array.prototype.map = function (...args: unknown[]) { + if (apply(includes, stringify(this), [marker])) observations++; + return apply(map, this, args); + }; + } + converted = convertToTextGenerationRuntimeMessages([message]); + } finally { + if (hooks) Array.prototype.map = map; + } + const assistantMessages = converted.filter((entry) => entry.role === "assistant"); + assertEquals(assistantMessages.length, 2); + assertEquals(assistantMessages.map((entry) => entry.providerMetadata), [ + { anthropic: { rawAssistantMessages: [[rawToolUse]] } }, + { anthropic: { rawAssistantMessages: [[rawText]] } }, + ]); + assertEquals(observations, 0); + }); + }); +} From bc626bdb0622b52dff7230da27313d6aae70bcb1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:32:34 +0200 Subject: [PATCH 177/194] fix(agent): traverse private lifecycle signals and frames by index --- src/agent/runtime/stream-lifecycle-shadow.ts | 77 ++++++++++++------- src/agent/streaming/lifecycle/runner.test.ts | 31 ++++++++ src/agent/streaming/lifecycle/runner.ts | 28 +++++-- .../agent/lifecycle-signal-iteration.test.ts | 67 ++++++++++++++++ 4 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 tests/integration/agent/lifecycle-signal-iteration.test.ts diff --git a/src/agent/runtime/stream-lifecycle-shadow.ts b/src/agent/runtime/stream-lifecycle-shadow.ts index 41c2a23cec..77a42c13bd 100644 --- a/src/agent/runtime/stream-lifecycle-shadow.ts +++ b/src/agent/runtime/stream-lifecycle-shadow.ts @@ -1,3 +1,11 @@ +import { + everyPrivateArray, + filterPrivateArray, + joinPrivateArray, + mapPrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { privateJsonParse, privateJsonStringify } from "#veryfront/security/private-json.ts"; import { stripLeadingEmptyObjectPlaceholder } from "#veryfront/agent/streaming/data-stream.ts"; import { @@ -16,6 +24,14 @@ import type { } from "./chat-stream-handler.ts"; import { compareStrings } from "#veryfront/utils/compare.ts"; +const hasOwn = Object.hasOwn; +const isArray = Array.isArray; +const objectIs = Object.is; +const objectKeys = Object.keys; +const arraySort = Array.prototype.sort; +const apply = Reflect.apply; +const sortStrings = (values: string[]): string[] => apply(arraySort, values, [compareStrings]); + export type StreamLifecycleShadowDivergence = | "text" | "reasoning" @@ -45,28 +61,25 @@ export function createStreamLifecycleShadow(options: { const decodeOptions = { availableToolNames: options.availableToolNames === null ? null - : new Set(options.availableToolNames), - providerExecutedToolNames: new Set(options.providerExecutedToolNames), + : createPrivateSet(options.availableToolNames), + providerExecutedToolNames: createPrivateSet(options.providerExecutedToolNames), }; return { observePart(part: unknown) { if (failed) return; try { - for ( - const signal of decodeRuntimeStreamPart( - part, - reducer.snapshot, - decodeOptions, - ) - ) { - reducer = reduceStreamSignal(reducer, signal, 0).state; + const signals = decodeRuntimeStreamPart(part, reducer.snapshot, decodeOptions); + for (let index = 0; index < signals.length; index++) { + if (hasOwn(signals, index)) { + reducer = reduceStreamSignal(reducer, signals[index]!, 0).state; + } } } catch { failed = true; } }, compareLegacySnapshot(state: ChatStreamState): StreamLifecycleShadowReport { - const categories = new Set(); + const categories = createPrivateSet(); if (failed) categories.add("shadow_error"); if (state.accumulatedText !== reducer.snapshot.accumulatedText) { categories.add("text"); @@ -88,7 +101,7 @@ export function createStreamLifecycleShadow(options: { } const report: StreamLifecycleShadowReport = { count: categories.size, - categories: [...categories].sort(compareStrings), + categories: apply(arraySort, [...categories], [compareStrings]), }; try { recordStreamLifecycleShadowReport({ report, mode: "shadow" }); @@ -104,8 +117,8 @@ function equalReasoning( legacy: readonly StreamingReasoningPart[], lifecycle: readonly { id: string; text: string }[], ): boolean { - return legacy.map((part) => part.text).join("\0") === - lifecycle.map((part) => part.text).join("\0"); + return joinPrivateArray(mapPrivateArray(legacy, (part) => part.text), "\0") === + joinPrivateArray(mapPrivateArray(lifecycle, (part) => part.text), "\0"); } function normalizeArgumentText(raw: string): string { @@ -122,9 +135,13 @@ function equalToolInputs( legacy: ReadonlyMap, tools: readonly StreamToolSnapshot[], ): boolean { - const lifecycleTools = tools.filter((tool) => tool.rejectionReason !== "unavailable"); + const lifecycleTools = filterPrivateArray( + tools, + (tool) => tool.rejectionReason !== "unavailable", + ); if (legacy.size !== lifecycleTools.length) return false; - for (const tool of lifecycleTools) { + for (let index = 0; index < lifecycleTools.length; index++) { + const tool = lifecycleTools[index]!; const match = legacy.get(tool.id); if (!match || match.name !== tool.name) return false; if ( @@ -137,7 +154,7 @@ function equalToolInputs( return true; } -const PROVIDER_TERMINAL_TOOL_PHASES = new Set([ +const PROVIDER_TERMINAL_TOOL_PHASES = createPrivateSet([ "succeeded", "failed", "denied", @@ -148,13 +165,15 @@ function equalToolResults( legacy: readonly StreamingToolResult[], tools: readonly StreamToolSnapshot[], ): boolean { - const terminal = tools.filter((tool) => + const terminal = filterPrivateArray(tools, (tool) => tool.providerExecuted === true && - PROVIDER_TERMINAL_TOOL_PHASES.has(tool.phase) - ); + PROVIDER_TERMINAL_TOOL_PHASES.has(tool.phase)); if (legacy.length !== terminal.length) return false; - const legacyById = new Map(legacy.map((result) => [result.toolCallId, result])); - return terminal.every((tool) => { + const legacyById = createPrivateMap(); + for (let index = 0; index < legacy.length; index++) { + if (hasOwn(legacy, index)) legacyById.set(legacy[index]!.toolCallId, legacy[index]!); + } + return everyPrivateArray(terminal, (tool) => { const result = legacyById.get(tool.id); return result !== undefined && equalToolResult(result, tool); }); @@ -181,22 +200,22 @@ function equalToolResult( } function deepEqualUnknown(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; + if (objectIs(a, b)) return true; if (typeof a !== typeof b) return false; if (a === null || b === null) return false; if (typeof a !== "object" || typeof b !== "object") return false; - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + if (isArray(a) || isArray(b)) { + if (!isArray(a) || !isArray(b) || a.length !== b.length) { return false; } - return a.every((value, index) => deepEqualUnknown(value, b[index])); + return everyPrivateArray(a, (value, index) => deepEqualUnknown(value, b[index])); } const aRecord = a as Record; const bRecord = b as Record; - const aKeys = Object.keys(aRecord).sort(compareStrings); - const bKeys = Object.keys(bRecord).sort(compareStrings); + const aKeys = sortStrings(objectKeys(aRecord)); + const bKeys = sortStrings(objectKeys(bRecord)); if (!deepEqualUnknown(aKeys, bKeys)) return false; - return aKeys.every((key) => deepEqualUnknown(aRecord[key], bRecord[key])); + return everyPrivateArray(aKeys, (key) => deepEqualUnknown(aRecord[key], bRecord[key])); } function equalUsage( diff --git a/src/agent/streaming/lifecycle/runner.test.ts b/src/agent/streaming/lifecycle/runner.test.ts index 63134915ef..eb353b1ab4 100644 --- a/src/agent/streaming/lifecycle/runner.test.ts +++ b/src/agent/streaming/lifecycle/runner.test.ts @@ -17,6 +17,37 @@ import type { } from "./types.ts"; describe("runStreamLifecycle", () => { + it("consumes decoded signals without consulting their own array iterator", async () => { + const signals: StreamSignal[] = [ + { kind: "protocol", event: { type: "text_content", delta: "synthetic private signal" } }, + { kind: "protocol", event: { type: "step_finish", finishReason: "stop" } }, + ]; + let reads = 0; + Object.defineProperty(signals, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + const provider: StreamProviderAdapter = { + open: async function* () { + yield {}; + }, + decode: () => signals, + classifyError: () => ({ + code: "PROVIDER_STREAM_ERROR", + publicMessage: "Failed", + retryable: false, + terminal: true, + }), + }; + const run = runStreamLifecycle({ provider }); + const frames = []; + for await (const frame of run.frames) frames.push(frame); + assertEquals((await run.outcome).snapshot.accumulatedText, "synthetic private signal"); + assertEquals(frames.length > 0, true); + assertEquals(reads, 0); + }); it("opens lazily and rejects a second frame consumer", async () => { const provider = createScriptedStreamProvider([]); const run = runStreamLifecycle({ provider }); diff --git a/src/agent/streaming/lifecycle/runner.ts b/src/agent/streaming/lifecycle/runner.ts index 80c33a76a4..2c23641c9d 100644 --- a/src/agent/streaming/lifecycle/runner.ts +++ b/src/agent/streaming/lifecycle/runner.ts @@ -35,6 +35,8 @@ import type { type StreamProviderSignal = StreamSignal; +const hasOwn = Object.hasOwn; + export function runStreamLifecycle( input: StreamLifecycleInput, ): StreamLifecycleRun { @@ -170,9 +172,19 @@ export function runStreamLifecycle( deadlines.pauseProviderWait(); if (raced.kind === "status") { - for (const toolCallId of raced.toolCallIds) { + for (let index = 0; index < raced.toolCallIds.length; index++) { + if (!hasOwn(raced.toolCallIds, index)) continue; + const toolCallId = raced.toolCallIds[index]!; if (outcome.settled) return; - const tool = reducer.snapshot.tools.find((entry) => entry.id === toolCallId); + let tool; + for (let toolIndex = 0; toolIndex < reducer.snapshot.tools.length; toolIndex++) { + if (!hasOwn(reducer.snapshot.tools, toolIndex)) continue; + const entry = reducer.snapshot.tools[toolIndex]!; + if (entry.id === toolCallId) { + tool = entry; + break; + } + } if ( !tool || (tool.phase !== "input_open" && tool.phase !== "input_streaming") @@ -221,7 +233,9 @@ export function runStreamLifecycle( reducer = { ...reducer, terminal: true, snapshot: failed.snapshot }; outcome.settle(failed); } - for (const frame of resolved.reduction.frames) { + for (let index = 0; index < resolved.reduction.frames.length; index++) { + if (!hasOwn(resolved.reduction.frames, index)) continue; + const frame = resolved.reduction.frames[index]!; notifyObserver(() => observer?.onFrame(frame)); yield frame; } @@ -293,7 +307,9 @@ export function runStreamLifecycle( ); return; } - for (const signal of signals) { + for (let signalIndex = 0; signalIndex < signals.length; signalIndex++) { + if (!hasOwn(signals, signalIndex)) continue; + const signal = signals[signalIndex]!; if (signal.kind === "diagnostic_candidate") { const safe = acceptDiagnosticCandidate(diagnostics, signal.candidate); if (safe) { @@ -346,7 +362,9 @@ export function runStreamLifecycle( if (terminalCommitted) { settleReducerTerminal(outcome, reducer, elapsedMs()); } - for (const frame of reduced.frames) { + for (let index = 0; index < reduced.frames.length; index++) { + if (!hasOwn(reduced.frames, index)) continue; + const frame = reduced.frames[index]!; if (!terminalCommitted && outcome.settled) return; notifyObserver(() => observer?.onFrame(frame)); yield frame; diff --git a/tests/integration/agent/lifecycle-signal-iteration.test.ts b/tests/integration/agent/lifecycle-signal-iteration.test.ts new file mode 100644 index 0000000000..e27720f5d1 --- /dev/null +++ b/tests/integration/agent/lifecycle-signal-iteration.test.ts @@ -0,0 +1,67 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + createRuntimeStreamSource, + createStreamState, + processStream, +} from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { + createMockResult, + createSSECollector, +} from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private lifecycle signal iteration ${hooks ? "hooks" : "baseline"}`, () => { + for (const mode of ["shadow", "active"] as const) { + it(`preserves private provider output in ${mode} without shared signal or frame iteration`, async () => { + const marker = "synthetic-private-lifecycle-signal"; + const result = createMockResult([ + { type: "reasoning-start", id: "reasoning" }, + { type: "reasoning-delta", id: "reasoning", delta: marker }, + { type: "reasoning-end", id: "reasoning" }, + { type: "text-delta", text: marker }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]); + const state = createStreamState(); + const { controller, encoder } = createSSECollector(); + const iterator = Array.prototype[Symbol.iterator]; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + try { + if (hooks) { + Array.prototype[Symbol.iterator] = function () { + for (let index = 0; index < this.length; index++) { + const value = this[index]; + if ( + value && typeof value === "object" && + (value.kind === "protocol" || value.class === "semantic") && + apply(includes, stringify(value), [marker]) + ) observations++; + } + return apply(iterator, this, []); + }; + } + await processStream( + mode === "active" ? createRuntimeStreamSource(() => result) : result, + state, + controller, + encoder, + "text", + { + streamLifecycleMode: mode, + }, + ); + } finally { + if (hooks) Array.prototype[Symbol.iterator] = iterator; + } + assertEquals(state.accumulatedText, marker); + assertEquals(state.reasoningParts[0]?.text, marker); + assertEquals(state.finishReason, "stop"); + assertEquals(observations, 0); + }); + } + }); +} From 73b48fb0d7740261fc164422cc7b3944e437b5ea Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:44:51 +0200 Subject: [PATCH 178/194] fix(agent): validate message IDs with captured trimming --- src/agent/runtime/input-utils.ts | 3 +- ...rovider-optional-fields-intrinsics.test.ts | 34 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/agent/runtime/input-utils.ts b/src/agent/runtime/input-utils.ts index 613c969eba..b5033ab3b6 100644 --- a/src/agent/runtime/input-utils.ts +++ b/src/agent/runtime/input-utils.ts @@ -1,4 +1,5 @@ import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { privateTextTrim } from "#veryfront/security/private-text.ts"; import type { Message } from "#veryfront/agent/types.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors"; import { @@ -70,7 +71,7 @@ export function normalizeInput(input: string | Message[]): Message[] { } return mapPrivateArray(input, (msg, index) => { - if (typeof msg.id === "string" && msg.id.trim().length === 0) { + if (typeof msg.id === "string" && privateTextTrim(msg.id).length === 0) { throw INVALID_ARGUMENT.create({ detail: "Message id cannot be empty." }); } diff --git a/tests/integration/agent/provider-optional-fields-intrinsics.test.ts b/tests/integration/agent/provider-optional-fields-intrinsics.test.ts index c41309f307..495492f56b 100644 --- a/tests/integration/agent/provider-optional-fields-intrinsics.test.ts +++ b/tests/integration/agent/provider-optional-fields-intrinsics.test.ts @@ -1,14 +1,46 @@ import "#veryfront/schemas/_test-setup.ts"; import { convertToTextGenerationRuntimeMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; import { cleanContent } from "#veryfront/chat/provider-message-content.ts"; +import { normalizeInput } from "#veryfront/agent/runtime/input-utils.ts"; import { securityMiddleware } from "#veryfront/agent/middleware/security/validator.ts"; import { getTurnProviderRequestValidator } from "#veryfront/agent/middleware/turn-validation.ts"; import type { AgentContext, Message } from "#veryfront/agent/types.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; for (const hooks of [false, true]) { describe(`private provider optional fields ${hooks ? "hooks" : "baseline"}`, () => { + it("validates message ids without exposing them to a replaced trim method", () => { + const marker = " synthetic-private-message-id "; + const input: Message[] = [{ + id: marker, + role: "user", + parts: [{ type: "text", text: "hello" }], + timestamp: 1, + }]; + const trim = String.prototype.trim; + const apply = Reflect.apply; + let observations = 0; + let normalized; + try { + if (hooks) { + String.prototype.trim = function () { + if (this === marker) observations++; + return apply(trim, this, []); + }; + } + normalized = normalizeInput(input); + assertThrows( + () => normalizeInput([{ id: " \t ", role: "user", parts: [] }]), + Error, + "Message id cannot be empty", + ); + } finally { + if (hooks) String.prototype.trim = trim; + } + assertEquals(normalized, input); + assertEquals(observations, 0); + }); it("keeps attachment data out of inherited filename and data getters", () => { const marker = "synthetic-private-attachment-fields"; const part = { From acd55e921af058fced26c5a7d476eaae5d574e52 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:46:02 +0200 Subject: [PATCH 179/194] fix(agent): reject broker credentials in direct ingress --- docs/guides/agent-service-runtime.md | 5 +- src/agent/service/broker-credentials.ts | 23 +++++++ src/agent/service/broker-ingress.ts | 22 ++----- .../service/managed-hosted-ingress.test.ts | 62 ++++++++++++++++++- src/agent/service/managed-hosted-ingress.ts | 30 +++++++-- 5 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 src/agent/service/broker-credentials.ts diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 8764d95438..126f26f32d 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -452,8 +452,9 @@ control-plane HTTP adapter. The broker installs invocation grants, describes the selected agent, and prepares the executor before accepting a run. It keeps model and tool execution unavailable during preparation. Configure detached 202 responses or request-owned SSE responses in trusted service configuration. -Signed invocations reject application strings or property names containing a known -broker credential, including credentials embedded in messages or attachment URLs. +Signed, direct durable, and direct AG-UI ingress reject application strings or property +names containing a known broker credential, including credentials embedded in messages +or attachment URLs. Detached runs require output persistence callbacks; their finalization remains part of the session's owned work until all writes settle. diff --git a/src/agent/service/broker-credentials.ts b/src/agent/service/broker-credentials.ts new file mode 100644 index 0000000000..16ef7c52a3 --- /dev/null +++ b/src/agent/service/broker-credentials.ts @@ -0,0 +1,23 @@ +/** Detect known broker credentials in bounded application strings and property names. */ +export function containsBrokerCredential( + value: unknown, + credentials: readonly (string | null | undefined)[], +): boolean { + for (const credential of credentials) { + if (!credential) continue; + if (containsString(value, credential)) return true; + const bearer = /^Bearer\s+(.+)$/i.exec(credential)?.[1]; + if (bearer && containsString(value, bearer)) return true; + } + return false; +} + +function containsString(value: unknown, expected: string): boolean { + if (typeof value === "string") return value.includes(expected); + if (!value || typeof value !== "object") return false; + return Array.isArray(value) + ? value.some((entry) => containsString(entry, expected)) + : Object.entries(value).some(([key, entry]) => + key.includes(expected) || containsString(entry, expected) + ); +} diff --git a/src/agent/service/broker-ingress.ts b/src/agent/service/broker-ingress.ts index 6a55f3c7ca..c7c58d9600 100644 --- a/src/agent/service/broker-ingress.ts +++ b/src/agent/service/broker-ingress.ts @@ -1,3 +1,4 @@ +import { containsBrokerCredential } from "#veryfront/agent/service/broker-credentials.ts"; import type { ControlPlaneClaims, ControlPlaneSurface } from "#veryfront/channels/control-plane.ts"; import { CONTROL_PLANE_JWS_HEADER, @@ -258,18 +259,15 @@ export async function parseBrokerRuntimeAgentIngress( input: toRuntimeRunAgentInput(parsedInbound.data), } satisfies BrokerRuntimeAgentExecutorInput, ); - for ( - const token of [ + if ( + containsBrokerCredential(executorValue, [ inboundAuthorization, - /^Bearer\s+(.+)$/i.exec(inboundAuthorization)?.[1], apiAuthToken, runEventToken, invocation.credentials?.inferenceAuthToken, - ] + ]) ) { - if (token && containsString(executorValue, token)) { - throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); - } + throw new BrokerIngressError(403, "BROKER_INGRESS_SCOPE_DENIED"); } return { privateAuthority: Object.freeze({ @@ -304,13 +302,3 @@ function containsForwardedAuthority(value: unknown): boolean { } return false; } - -function containsString(value: unknown, expected: string): boolean { - if (typeof value === "string") return value.includes(expected); - if (!value || typeof value !== "object") return false; - return Array.isArray(value) - ? value.some((entry) => containsString(entry, expected)) - : Object.entries(value).some(([key, entry]) => - key.includes(expected) || containsString(entry, expected) - ); -} diff --git a/src/agent/service/managed-hosted-ingress.test.ts b/src/agent/service/managed-hosted-ingress.test.ts index 388784ada0..7fbdce3f6f 100644 --- a/src/agent/service/managed-hosted-ingress.test.ts +++ b/src/agent/service/managed-hosted-ingress.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { parseManagedAgUiAgentIngress, @@ -19,6 +19,66 @@ function verifyProjectAccess() { } describe("managed agent ingress", () => { + for (const kind of ["durable", "ag-ui"] as const) { + for ( + const credential of [ + "broker-auth-secret", + "request-auth-secret", + "run-event-secret", + "inference-secret", + ] + ) { + for (const placement of ["message", "url", "property name"] as const) { + it(`rejects ${credential} embedded in ${kind} ${placement}`, async () => { + const text = placement === "message" ? `Use ${credential} for this request` : "Hello"; + const extra = placement === "url" + ? { + attachments: [{ url: `https://files.test/document?token=${credential}&download=1` }], + } + : placement === "property name" + ? { [`result-${credential}-metadata`]: "value" } + : {}; + const payload = kind === "durable" + ? { + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text }] }], + context: { conversationId, projectId, branchId: "branch-1" }, + durableRootRun: { runId: "run_root_1", messageId }, + forwardedProps: extra, + } + : { + threadId: conversationId, + runId: "run-1", + messages: [{ id: "m1", role: "user", content: text }], + tools: [], + context: [{ description: "veryfront.projectId", value: JSON.stringify(projectId) }], + state: extra, + }; + const request = new Request(`https://agent.example.test/api/${kind}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer request-auth-secret", + "X-Veryfront-Run-Event-Token": "run-event-secret", + "X-Veryfront-Inference-Token": "inference-secret", + }, + body: JSON.stringify(payload), + }); + const options = { + authenticate, + verifyProjectAccess, + verifyRunEventAppendToken: () => Promise.resolve(true), + }; + const result = kind === "durable" + ? await parseManagedDurableAgentIngress(request, options) + : await parseManagedAgUiAgentIngress(request, options); + assert(result instanceof Response); + assertEquals(result.status, 403); + assertEquals(await result.json(), { errorCode: "BROKER_INGRESS_SCOPE_DENIED" }); + }); + } + } + } + it("separates durable broker authority from a detached executor request", async () => { const request = new Request("https://agent.example.test/api/runs", { method: "POST", diff --git a/src/agent/service/managed-hosted-ingress.ts b/src/agent/service/managed-hosted-ingress.ts index efcaa479f2..51d0e04d72 100644 --- a/src/agent/service/managed-hosted-ingress.ts +++ b/src/agent/service/managed-hosted-ingress.ts @@ -1,3 +1,4 @@ +import { containsBrokerCredential } from "#veryfront/agent/service/broker-credentials.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; import { @@ -14,9 +15,11 @@ import { type HostedRunEventWriterCapability, } from "../hosted/child-run-event-writer-token.ts"; import { + INFERENCE_TOKEN_HEADER, type ParsedHostedChatRequest, parseHostedChatRequestFromRequest, type ParseHostedChatRequestOptions, + RUN_EVENT_APPEND_TOKEN_HEADER, } from "../hosted/chat-request-parser.ts"; import { createHostedInferenceModelResolver } from "../hosted/inference-credential.ts"; import type { AgentModelRuntimeResolver } from "../runtime/model-transport.ts"; @@ -180,11 +183,20 @@ function createBrokerAuthority( return Object.freeze(authority); } +function requestCredentials(request: Request): (string | null)[] { + return [ + request.headers.get("authorization"), + request.headers.get(RUN_EVENT_APPEND_TOKEN_HEADER), + request.headers.get(INFERENCE_TOKEN_HEADER), + ]; +} + function createExecutorRequest( kind: ManagedAgentIngressKind, parsedRequest: ParsedHostedChatRequest, + credentials: readonly (string | null)[], agUiInput?: ParsedHostedAgUiRequest["agUiInput"], -): ManagedAgentExecutorRequest { +): ManagedAgentExecutorRequest | Response { const request = { protocolVersion: 1, kind, @@ -230,7 +242,11 @@ function createExecutorRequest( } : {}), }; - return boundedExecutorRequest(request); + const executor = boundedExecutorRequest(request); + if (containsBrokerCredential(executor, [parsedRequest.authToken, ...credentials])) { + return Response.json({ errorCode: "BROKER_INGRESS_SCOPE_DENIED" }, { status: 403 }); + } + return executor; } /** Parse the trusted broker's direct canonical durable-run ingress. */ @@ -238,12 +254,15 @@ export async function parseManagedDurableAgentIngress( request: Request, options: ParseHostedChatRequestOptions, ): Promise { + const credentials = requestCredentials(request); const parsedRequest = await parseHostedChatRequestFromRequest(request, options); if (isResponseLike(parsedRequest)) return parsedRequest; + const executor = createExecutorRequest("durable", parsedRequest, credentials); + if (isResponseLike(executor)) return executor; return Object.freeze({ kind: "durable" as const, broker: createBrokerAuthority(parsedRequest), - executor: createExecutorRequest("durable", parsedRequest) as + executor: executor as & ManagedAgentExecutorRequest & { kind: "durable" }, }); @@ -254,6 +273,7 @@ export async function parseManagedAgUiAgentIngress( request: Request, options: ParseManagedAgUiAgentIngressOptions, ): Promise { + const credentials = requestCredentials(request); const applicationRequest = removeRetainedInfrastructureHeaders( createApplicationRequest(request), ); @@ -273,11 +293,13 @@ export async function parseManagedAgUiAgentIngress( verifyProjectAccess: options.verifyProjectAccess, }); if (isResponseLike(parsedRequest)) return parsedRequest; + const executor = createExecutorRequest("ag-ui", parsedRequest, credentials, agUiInput); + if (isResponseLike(executor)) return executor; return Object.freeze({ kind: "ag-ui" as const, broker: createBrokerAuthority(parsedRequest), - executor: createExecutorRequest("ag-ui", parsedRequest, agUiInput) as + executor: executor as & ManagedAgentExecutorRequest & { kind: "ag-ui"; agUi: ManagedAgentExecutorAgUiState }, }); From c2dadca2f120643c3f916416d65e3ef3081bb1c8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 9 Sep 2026 23:53:19 +0200 Subject: [PATCH 180/194] fix(agent): keep message provenance in private weak stores --- src/agent/runtime/input-utils.ts | 39 ++++++------ .../message-provenance-intrinsics.test.ts | 62 +++++++++++++++++++ 2 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 tests/integration/agent/message-provenance-intrinsics.test.ts diff --git a/src/agent/runtime/input-utils.ts b/src/agent/runtime/input-utils.ts index b5033ab3b6..7114834557 100644 --- a/src/agent/runtime/input-utils.ts +++ b/src/agent/runtime/input-utils.ts @@ -1,4 +1,5 @@ import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateWeakStore } from "#veryfront/security/private-weak-store.ts"; import { privateTextTrim } from "#veryfront/security/private-text.ts"; import type { Message } from "#veryfront/agent/types.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors"; @@ -7,19 +8,19 @@ import { markRuntimeGeneratedUserMessage, } from "./runtime-message-origin.ts"; -const syntheticMessageIds = new WeakSet(); -const syntheticMessageTimestamps = new WeakSet(); -const syntheticMessageIdValues = new WeakMap(); -const syntheticMessageTimestampValues = new WeakMap(); +const syntheticMessageIds = createPrivateWeakStore(); +const syntheticMessageTimestamps = createPrivateWeakStore(); +const syntheticMessageIdValues = createPrivateWeakStore(); +const syntheticMessageTimestampValues = createPrivateWeakStore(); /** Whether normalization supplied this message id from the wall clock. */ export function hasSyntheticMessageId(message: Message): boolean { - return syntheticMessageIds.has(message); + return syntheticMessageIds.get(message) === true; } /** Whether normalization supplied this message timestamp from the wall clock. */ export function hasSyntheticMessageTimestamp(message: Message): boolean { - return syntheticMessageTimestamps.has(message); + return syntheticMessageTimestamps.get(message) === true; } /** Whether this message still carries the id supplied by normalization. */ @@ -27,7 +28,7 @@ export function hasUnchangedSyntheticMessageId( message: Message, id: string, ): boolean { - return syntheticMessageIds.has(message) && syntheticMessageIdValues.get(message) === id; + return syntheticMessageIds.get(message) === true && syntheticMessageIdValues.get(message) === id; } /** Whether this message still carries the timestamp supplied by normalization. */ @@ -35,19 +36,19 @@ export function hasUnchangedSyntheticMessageTimestamp( message: Message, timestamp: number | undefined, ): boolean { - return syntheticMessageTimestamps.has(message) && + return syntheticMessageTimestamps.get(message) === true && syntheticMessageTimestampValues.get(message) === timestamp; } /** Preserve synthesized-field provenance when middleware clones a message. */ export function propagateSyntheticMessageMarks(source: Message, target: Message): void { - if (syntheticMessageIds.has(source)) { - syntheticMessageIds.add(target); + if (syntheticMessageIds.get(source) === true) { + syntheticMessageIds.set(target, true); const id = syntheticMessageIdValues.get(source); if (id !== undefined) syntheticMessageIdValues.set(target, id); } - if (syntheticMessageTimestamps.has(source)) { - syntheticMessageTimestamps.add(target); + if (syntheticMessageTimestamps.get(source) === true) { + syntheticMessageTimestamps.set(target, true); const timestamp = syntheticMessageTimestampValues.get(source); if (timestamp !== undefined) syntheticMessageTimestampValues.set(target, timestamp); } @@ -63,8 +64,8 @@ export function normalizeInput(input: string | Message[]): Message[] { parts: [{ type: "text", text: input }], timestamp: now, }; - syntheticMessageIds.add(message); - syntheticMessageTimestamps.add(message); + syntheticMessageIds.set(message, true); + syntheticMessageTimestamps.set(message, true); syntheticMessageIdValues.set(message, message.id); syntheticMessageTimestampValues.set(message, message.timestamp!); return [message]; @@ -81,17 +82,17 @@ export function normalizeInput(input: string | Message[]): Message[] { timestamp: msg.timestamp ?? now, }; if (msg.id == null) { - syntheticMessageIds.add(normalized); + syntheticMessageIds.set(normalized, true); syntheticMessageIdValues.set(normalized, normalized.id); - } else if (syntheticMessageIds.has(msg)) { - syntheticMessageIds.add(normalized); + } else if (syntheticMessageIds.get(msg) === true) { + syntheticMessageIds.set(normalized, true); syntheticMessageIdValues.set( normalized, syntheticMessageIdValues.get(msg) ?? normalized.id, ); } - if (msg.timestamp == null || syntheticMessageTimestamps.has(msg)) { - syntheticMessageTimestamps.add(normalized); + if (msg.timestamp == null || syntheticMessageTimestamps.get(msg) === true) { + syntheticMessageTimestamps.set(normalized, true); syntheticMessageTimestampValues.set( normalized, msg.timestamp == null diff --git a/tests/integration/agent/message-provenance-intrinsics.test.ts b/tests/integration/agent/message-provenance-intrinsics.test.ts new file mode 100644 index 0000000000..152c9f09a6 --- /dev/null +++ b/tests/integration/agent/message-provenance-intrinsics.test.ts @@ -0,0 +1,62 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + hasSyntheticMessageId, + hasSyntheticMessageTimestamp, + hasUnchangedSyntheticMessageId, + hasUnchangedSyntheticMessageTimestamp, + normalizeInput, + propagateSyntheticMessageMarks, +} from "#veryfront/agent/runtime/input-utils.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private message provenance ${hooks ? "hooks" : "baseline"}`, () => { + it("preserves normalization marks without exposing messages through weak collections", () => { + const marker = "synthetic-private-normalized-message"; + const apply = Reflect.apply; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const defineProperty = Object.defineProperty; + const methods = [ + { target: WeakMap.prototype, key: "get" }, + { target: WeakMap.prototype, key: "set" }, + { target: WeakSet.prototype, key: "has" }, + { target: WeakSet.prototype, key: "add" }, + ].map((entry) => ({ + ...entry, + descriptor: Object.getOwnPropertyDescriptor(entry.target, entry.key)!, + })); + let observations = 0; + let generated, normalized, copied; + let idMarked, timeMarked, idUnchanged, timeUnchanged; + try { + if (hooks) { + for (const method of methods) { + defineProperty(method.target, method.key, { + ...method.descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (apply(includes, stringify(args[0]) ?? "", [marker])) observations++; + return apply(method.descriptor.value, this, args); + }, + }); + } + } + generated = normalizeInput(marker)[0]!; + normalized = normalizeInput([generated])[0]!; + copied = { ...normalized }; + propagateSyntheticMessageMarks(normalized, copied); + idMarked = hasSyntheticMessageId(copied); + timeMarked = hasSyntheticMessageTimestamp(copied); + idUnchanged = hasUnchangedSyntheticMessageId(copied, copied.id); + timeUnchanged = hasUnchangedSyntheticMessageTimestamp(copied, copied.timestamp); + } finally { + for (const method of methods) defineProperty(method.target, method.key, method.descriptor); + } + assertEquals(observations, 0); + assertEquals(normalized?.parts, [{ type: "text", text: marker }]); + assertEquals(copied, generated); + assertEquals([idMarked, timeMarked, idUnchanged, timeUnchanged], [true, true, true, true]); + }); + }); +} From 002e1f2c10527c5cc9f213d7fb7874f23d91b222 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 00:00:03 +0200 Subject: [PATCH 181/194] fix(agent): protect provenance and remote source collection boundaries --- src/agent/runtime/input-utils.ts | 39 +++--- .../runtime/mcp-server-tool-sources.test.ts | 43 ++++++ src/agent/runtime/mcp-server-tool-sources.ts | 60 +++++--- src/agent/runtime/stream-lifecycle-shadow.ts | 6 +- src/agent/runtime/tool-helpers.test.ts | 45 ++++++ src/agent/runtime/tool-helpers.ts | 11 +- .../lifecycle/runtime-provider-adapter.ts | 3 +- src/agent/streaming/lifecycle/tool-input.ts | 4 +- .../agent/lifecycle-signal-intrinsics.test.ts | 128 ++++++++++++++++++ .../message-provenance-intrinsics.test.ts | 62 +++++++++ ...emote-source-collection-intrinsics.test.ts | 109 +++++++++++++++ 11 files changed, 469 insertions(+), 41 deletions(-) create mode 100644 tests/integration/agent/lifecycle-signal-intrinsics.test.ts create mode 100644 tests/integration/agent/message-provenance-intrinsics.test.ts create mode 100644 tests/integration/agent/remote-source-collection-intrinsics.test.ts diff --git a/src/agent/runtime/input-utils.ts b/src/agent/runtime/input-utils.ts index b5033ab3b6..7114834557 100644 --- a/src/agent/runtime/input-utils.ts +++ b/src/agent/runtime/input-utils.ts @@ -1,4 +1,5 @@ import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateWeakStore } from "#veryfront/security/private-weak-store.ts"; import { privateTextTrim } from "#veryfront/security/private-text.ts"; import type { Message } from "#veryfront/agent/types.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors"; @@ -7,19 +8,19 @@ import { markRuntimeGeneratedUserMessage, } from "./runtime-message-origin.ts"; -const syntheticMessageIds = new WeakSet(); -const syntheticMessageTimestamps = new WeakSet(); -const syntheticMessageIdValues = new WeakMap(); -const syntheticMessageTimestampValues = new WeakMap(); +const syntheticMessageIds = createPrivateWeakStore(); +const syntheticMessageTimestamps = createPrivateWeakStore(); +const syntheticMessageIdValues = createPrivateWeakStore(); +const syntheticMessageTimestampValues = createPrivateWeakStore(); /** Whether normalization supplied this message id from the wall clock. */ export function hasSyntheticMessageId(message: Message): boolean { - return syntheticMessageIds.has(message); + return syntheticMessageIds.get(message) === true; } /** Whether normalization supplied this message timestamp from the wall clock. */ export function hasSyntheticMessageTimestamp(message: Message): boolean { - return syntheticMessageTimestamps.has(message); + return syntheticMessageTimestamps.get(message) === true; } /** Whether this message still carries the id supplied by normalization. */ @@ -27,7 +28,7 @@ export function hasUnchangedSyntheticMessageId( message: Message, id: string, ): boolean { - return syntheticMessageIds.has(message) && syntheticMessageIdValues.get(message) === id; + return syntheticMessageIds.get(message) === true && syntheticMessageIdValues.get(message) === id; } /** Whether this message still carries the timestamp supplied by normalization. */ @@ -35,19 +36,19 @@ export function hasUnchangedSyntheticMessageTimestamp( message: Message, timestamp: number | undefined, ): boolean { - return syntheticMessageTimestamps.has(message) && + return syntheticMessageTimestamps.get(message) === true && syntheticMessageTimestampValues.get(message) === timestamp; } /** Preserve synthesized-field provenance when middleware clones a message. */ export function propagateSyntheticMessageMarks(source: Message, target: Message): void { - if (syntheticMessageIds.has(source)) { - syntheticMessageIds.add(target); + if (syntheticMessageIds.get(source) === true) { + syntheticMessageIds.set(target, true); const id = syntheticMessageIdValues.get(source); if (id !== undefined) syntheticMessageIdValues.set(target, id); } - if (syntheticMessageTimestamps.has(source)) { - syntheticMessageTimestamps.add(target); + if (syntheticMessageTimestamps.get(source) === true) { + syntheticMessageTimestamps.set(target, true); const timestamp = syntheticMessageTimestampValues.get(source); if (timestamp !== undefined) syntheticMessageTimestampValues.set(target, timestamp); } @@ -63,8 +64,8 @@ export function normalizeInput(input: string | Message[]): Message[] { parts: [{ type: "text", text: input }], timestamp: now, }; - syntheticMessageIds.add(message); - syntheticMessageTimestamps.add(message); + syntheticMessageIds.set(message, true); + syntheticMessageTimestamps.set(message, true); syntheticMessageIdValues.set(message, message.id); syntheticMessageTimestampValues.set(message, message.timestamp!); return [message]; @@ -81,17 +82,17 @@ export function normalizeInput(input: string | Message[]): Message[] { timestamp: msg.timestamp ?? now, }; if (msg.id == null) { - syntheticMessageIds.add(normalized); + syntheticMessageIds.set(normalized, true); syntheticMessageIdValues.set(normalized, normalized.id); - } else if (syntheticMessageIds.has(msg)) { - syntheticMessageIds.add(normalized); + } else if (syntheticMessageIds.get(msg) === true) { + syntheticMessageIds.set(normalized, true); syntheticMessageIdValues.set( normalized, syntheticMessageIdValues.get(msg) ?? normalized.id, ); } - if (msg.timestamp == null || syntheticMessageTimestamps.has(msg)) { - syntheticMessageTimestamps.add(normalized); + if (msg.timestamp == null || syntheticMessageTimestamps.get(msg) === true) { + syntheticMessageTimestamps.set(normalized, true); syntheticMessageTimestampValues.set( normalized, msg.timestamp == null diff --git a/src/agent/runtime/mcp-server-tool-sources.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index b0ec9c99d5..f1b98b0eb1 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -11,6 +11,7 @@ import { constrainRuntimeRemoteToolSources, getRequestedUnresolvedBooleanToolNames, getRuntimeRemoteToolSources, + type RuntimeRemoteToolConfig, VERYFRONT_API_MCP_SOURCE_ID, VERYFRONT_STUDIO_MCP_SOURCE_ID, } from "./mcp-server-tool-sources.ts"; @@ -894,3 +895,45 @@ Deno.test("getRuntimeRemoteToolSources fails closed without Veryfront server ide ); assertEquals(error.slug, "config-invalid"); }); + +it("keeps injected remote facades out of source collection hooks", async () => { + let observations = 0; + let executions = 0; + const source: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => { + executions++; + return Promise.resolve({ ok: true }); + }, + }; + const sources = [source]; + const prototype = Object.create(Array.prototype); + for (const key of ["map", "filter", "some", Symbol.iterator] as const) { + const original = Array.prototype[key]; + Object.defineProperty(prototype, key, { + value: function (this: unknown[], ...args: unknown[]) { + observations++; + return Reflect.apply(original, this, args); + }, + }); + } + Object.setPrototypeOf(sources, prototype); + const config = { + system: "Use the selected remote tools.", + mcpServers: [{ kind: "veryfront-api" as const, toolPolicy: { allow: ["allowed"] } }], + __vfRemoteToolSources: sources, + } satisfies RuntimeRemoteToolConfig & Parameters[0]; + const selected = getRuntimeRemoteToolSources(config)!; + const constrained = constrainRuntimeRemoteToolSources(sources, ["allowed"])!; + const bound = bindRuntimeRemoteToolSourcesToCredentialOwner(sources, { + agentId: "synthetic-agent", + })!; + assertEquals(observations, 0); + assertEquals(selected.length, 1); + assertEquals(bound.length, 1); + assertThrows(() => selected[0]!.executeTool("blocked", {}), Error, "not allowed"); + assertThrows(() => constrained[0]!.executeTool("blocked", {}), Error, "not allowed"); + assertEquals(await selected[0]!.executeTool("allowed", {}), { ok: true }); + assertEquals(executions, 1); +}); diff --git a/src/agent/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index 3f900594b2..288322c2df 100644 --- a/src/agent/runtime/mcp-server-tool-sources.ts +++ b/src/agent/runtime/mcp-server-tool-sources.ts @@ -1,3 +1,12 @@ +import { + concatPrivateArrays, + filterPrivateArray, + flatMapPrivateArray, + mapPrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { createProjectScopedRemoteToolCatalog, createRemoteMCPToolSource, @@ -37,6 +46,8 @@ export const VERYFRONT_API_MCP_SOURCE_ID = "veryfront-platform-mcp"; export const VERYFRONT_STUDIO_MCP_SOURCE_ID = "studio-mcp"; const RUNTIME_PROVIDED_BOOLEAN_TOOL_NAMES = new Set(["bash", "invoke_agent"]); +const hasOwn = Object.hasOwn; +const isArray = Array.isArray; const applyIntrinsic = Reflect.apply; const stringTrim = String.prototype.trim; const stringReplace = String.prototype.replace; @@ -142,9 +153,9 @@ export function constrainRuntimeRemoteToolSources( return sources; } - const policy = { allow: [...new Set(allowedToolNames)] }; + const policy = { allow: [...createPrivateSet(allowedToolNames)] }; const sourcesToConstrain = sources ?? getActiveRuntimeRemoteToolSources() ?? []; - return sourcesToConstrain.map((source) => createMcpToolPolicySource(source, policy)); + return mapPrivateArray(sourcesToConstrain, (source) => createMcpToolPolicySource(source, policy)); } const REMOTE_TOOL_CREDENTIAL_CONTEXT_KEYS = [ @@ -194,7 +205,7 @@ export function bindRuntimeRemoteToolSourcesToCredentialOwner( return undefined; } - return sources.map((source) => ({ + return mapPrivateArray(sources, (source) => ({ id: source.id, listTools: (nestedContext) => source.listTools(withBoundRemoteToolContext(nestedContext, context, ["authToken"])), @@ -224,7 +235,7 @@ function withServerProject( } function withoutProjectReference(args: unknown): Record { - if (typeof args !== "object" || args === null || Array.isArray(args)) { + if (typeof args !== "object" || args === null || isArray(args)) { return {}; } const { project_reference: _untrustedProjectReference, ...toolInput } = args as Record< @@ -334,7 +345,7 @@ export function getRuntimeRemoteToolSources( agentId = config.id, ): RemoteToolSource[] | undefined { const runtimeConfig = config as AgentConfig & RuntimeRemoteToolConfig; - const configuredInjectedSources = Object.hasOwn(runtimeConfig, "__vfRemoteToolSources") + const configuredInjectedSources = hasOwn(runtimeConfig, "__vfRemoteToolSources") ? runtimeConfig.__vfRemoteToolSources ?? [] : undefined; const hasExplicitMcpServers = config.mcpServers !== undefined; @@ -349,27 +360,39 @@ export function getRuntimeRemoteToolSources( (implicitToolNames.length > 0 && configuredInjectedSources === undefined ? [{ kind: "veryfront-api", toolPolicy: { allow: implicitToolNames } }] : []); - const configuredFirstPartyServersBySourceId = new Map(); - for (const server of configuredServers) { + const configuredFirstPartyServersBySourceId = createPrivateMap< + string, + AgentVeryfrontMcpServerConfig + >(); + for (let index = 0; index < configuredServers.length; index++) { + const server = configuredServers[index]!; if (!isHttpMcpServerConfig(server)) { configuredFirstPartyServersBySourceId.set(getFirstPartyMcpSourceId(server), server); } } const selectedInjectedSources = hasExplicitMcpServers - ? injectedSources.filter((source) => configuredFirstPartyServersBySourceId.has(source.id)) + ? filterPrivateArray( + injectedSources, + (source) => configuredFirstPartyServersBySourceId.has(source.id), + ) : injectedSources; - const policyWrappedInjectedSources = selectedInjectedSources.map((source) => { + const policyWrappedInjectedSources = mapPrivateArray(selectedInjectedSources, (source) => { const server = configuredFirstPartyServersBySourceId.get(source.id); const policy = server?.toolPolicy ?? (implicitToolNames.length > 0 ? { allow: implicitToolNames } : undefined); return createMcpToolPolicySource(source, policy); }); - const configuredSources = configuredServers.flatMap((server) => { + const configuredSources = flatMapPrivateArray(configuredServers, (server) => { if (isHttpMcpServerConfig(server)) { return [createMcpServerToolSource(server)]; } if (server.kind === "veryfront-api") { - if (injectedSources.some((source) => source.id === getFirstPartyMcpSourceId(server))) { + if ( + somePrivateArray( + injectedSources, + (source) => source.id === getFirstPartyMcpSourceId(server), + ) + ) { return []; } const source = createVeryfrontApiMcpServerToolSource( @@ -380,17 +403,22 @@ export function getRuntimeRemoteToolSources( return source ? [source] : []; } if (server.kind === "veryfront-studio") { - if (injectedSources.some((source) => source.id === getFirstPartyMcpSourceId(server))) { + if ( + somePrivateArray( + injectedSources, + (source) => source.id === getFirstPartyMcpSourceId(server), + ) + ) { return []; } requiresInjectedStudioMcpServerToolSource(server); } return []; }); - const remoteToolSources = [ - ...policyWrappedInjectedSources, - ...configuredSources, - ]; + const remoteToolSources = concatPrivateArrays( + policyWrappedInjectedSources, + configuredSources, + ); if (remoteToolSources.length > 0) { return remoteToolSources; diff --git a/src/agent/runtime/stream-lifecycle-shadow.ts b/src/agent/runtime/stream-lifecycle-shadow.ts index 77a42c13bd..c5e2e5b20a 100644 --- a/src/agent/runtime/stream-lifecycle-shadow.ts +++ b/src/agent/runtime/stream-lifecycle-shadow.ts @@ -25,6 +25,8 @@ import type { import { compareStrings } from "#veryfront/utils/compare.ts"; const hasOwn = Object.hasOwn; +const mapGet = Map.prototype.get; +const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!; const isArray = Array.isArray; const objectIs = Object.is; const objectKeys = Object.keys; @@ -139,10 +141,10 @@ function equalToolInputs( tools, (tool) => tool.rejectionReason !== "unavailable", ); - if (legacy.size !== lifecycleTools.length) return false; + if (apply(mapSize, legacy, []) !== lifecycleTools.length) return false; for (let index = 0; index < lifecycleTools.length; index++) { const tool = lifecycleTools[index]!; - const match = legacy.get(tool.id); + const match = apply(mapGet, legacy, [tool.id]) as StreamingToolCall | undefined; if (!match || match.name !== tool.name) return false; if ( normalizeArgumentText(match.arguments ?? "") !== diff --git a/src/agent/runtime/tool-helpers.test.ts b/src/agent/runtime/tool-helpers.test.ts index 6794238cb9..1c95140fc3 100644 --- a/src/agent/runtime/tool-helpers.test.ts +++ b/src/agent/runtime/tool-helpers.test.ts @@ -1192,3 +1192,48 @@ describe("tool-helpers", () => { }); }); }); + +it("skips inherited entries while discovering and executing remote sources", async () => { + let observations = 0; + let executions = 0; + const source: RemoteToolSource = { + id: "synthetic-source", + listTools: () => + Promise.resolve([{ + name: "allowed", + description: "Allowed tool", + parameters: { type: "object" }, + }]), + executeTool: () => { + executions++; + return Promise.resolve({ ok: true }); + }, + }; + const sources: RemoteToolSource[] = []; + sources[1] = source; + const prototype = Object.create(Array.prototype); + Object.defineProperty(prototype, "0", { + get() { + observations++; + return source; + }, + }); + Object.setPrototypeOf(sources, prototype); + const definitions = await getAvailableTools({ allowed: true }, { + includeIntegrationTools: false, + remoteToolSources: sources, + allowedRemoteToolNames: ["allowed"], + }); + const result = await executeConfiguredTool( + "allowed", + {}, + { allowed: true }, + undefined, + ["allowed"], + sources, + ); + assertEquals(observations, 0); + assertEquals(definitions.map((definition) => definition.name), ["allowed"]); + assertEquals(result, { ok: true }); + assertEquals(executions, 1); +}); diff --git a/src/agent/runtime/tool-helpers.ts b/src/agent/runtime/tool-helpers.ts index 2e0ba456c6..fa214607b1 100644 --- a/src/agent/runtime/tool-helpers.ts +++ b/src/agent/runtime/tool-helpers.ts @@ -30,6 +30,7 @@ import { compareStrings } from "#veryfront/utils/compare.ts"; const logger = serverLogger.component("agent"); const intrinsicReflectApply = Reflect.apply; const intrinsicObjectEntries = Object.entries; +const intrinsicHasOwn = Object.hasOwn; const intrinsicArrayPush = Array.prototype.push; const intrinsicArrayIncludes = Array.prototype.includes; @@ -185,7 +186,10 @@ async function getRemoteToolDefinitions(options?: { intrinsicReflectApply(intrinsicArrayPush, definitions, [definition]); }; - for (const source of options?.remoteToolSources ?? []) { + const sources = options?.remoteToolSources ?? []; + for (let index = 0; index < sources.length; index++) { + if (!intrinsicHasOwn(sources, index)) continue; + const source = sources[index]!; try { const sourceDefs = await source.listTools(remoteToolContext); for (const def of sourceDefs) { @@ -236,7 +240,10 @@ async function executeRemoteToolFromSources( allowedRemoteToolNames: string[] | undefined, remoteToolSources: RemoteToolSource[] | undefined, ): Promise<{ handled: boolean; result?: unknown }> { - for (const source of remoteToolSources ?? []) { + const sources = remoteToolSources ?? []; + for (let index = 0; index < sources.length; index++) { + if (!intrinsicHasOwn(sources, index)) continue; + const source = sources[index]!; if (!(await sourceHasTool(source, toolName, context))) { continue; } diff --git a/src/agent/streaming/lifecycle/runtime-provider-adapter.ts b/src/agent/streaming/lifecycle/runtime-provider-adapter.ts index 19b0655e55..a97e6afc5a 100644 --- a/src/agent/streaming/lifecycle/runtime-provider-adapter.ts +++ b/src/agent/streaming/lifecycle/runtime-provider-adapter.ts @@ -18,6 +18,7 @@ import type { } from "./types.ts"; const hasOwn = Object.hasOwn; +const isArray = Array.isArray; export interface RuntimeStreamProviderOptions { availableToolNames: ReadonlySet | null; @@ -325,7 +326,7 @@ function toolReadySignals( const merged = mergeToolCallInput(streamed, finalText); const parsed = parseCanonicalToolInput( typeof typed.input === "object" && typed.input !== null && - !Array.isArray(typed.input) + !isArray(typed.input) ? typed.input : merged, ); diff --git a/src/agent/streaming/lifecycle/tool-input.ts b/src/agent/streaming/lifecycle/tool-input.ts index 4142153588..23f47a260a 100644 --- a/src/agent/streaming/lifecycle/tool-input.ts +++ b/src/agent/streaming/lifecycle/tool-input.ts @@ -1,12 +1,14 @@ import { privateJsonParse } from "#veryfront/security/private-json.ts"; import { stripLeadingEmptyObjectPlaceholder } from "#veryfront/agent/streaming/data-stream.ts"; +const isArray = Array.isArray; + export type CanonicalToolInputParseResult = | { ok: true; value: Record } | { ok: false; reason: "invalid" | "malformed" }; function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } export function parseCanonicalToolInput( diff --git a/tests/integration/agent/lifecycle-signal-intrinsics.test.ts b/tests/integration/agent/lifecycle-signal-intrinsics.test.ts new file mode 100644 index 0000000000..ee36362721 --- /dev/null +++ b/tests/integration/agent/lifecycle-signal-intrinsics.test.ts @@ -0,0 +1,128 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createStreamState } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { createStreamLifecycleShadow } from "#veryfront/agent/runtime/stream-lifecycle-shadow.ts"; +import { runStreamLifecycle } from "#veryfront/agent/streaming/lifecycle/runner.ts"; +import { createScriptedStreamProvider } from "#veryfront/agent/streaming/lifecycle/testing.ts"; +import type { StreamSignal } from "#veryfront/agent/streaming/lifecycle/types.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const mode of ["shadow", "active"] as const) { + for (const probe of ["baseline", "iterator", "projection", "map lookup", "reflection"] as const) { + describe(`private lifecycle signals ${mode} ${probe}`, () => { + it("preserves private content through signal reduction and comparison", async () => { + const marker = "synthetic-private-lifecycle-signal"; + const shadow = createStreamLifecycleShadow({ + availableToolNames: ["inspect"], + providerExecutedToolNames: ["inspect"], + }); + const legacy = { + ...createStreamState(), + accumulatedText: marker, + reasoningParts: [{ id: "r1", text: marker }], + finishReason: "stop", + toolCalls: new Map([["call", { + id: "call", + name: "inspect", + arguments: JSON.stringify({ text: marker }), + providerExecuted: true, + inputAvailable: true, + }]]), + toolResults: [{ + toolCallId: "call", + toolName: "inspect", + output: { items: [marker] }, + providerExecuted: true, + preliminary: false, + }], + }; + const provider = createScriptedStreamProvider([ + { kind: "protocol", event: { type: "text_content", delta: marker } }, + { kind: "protocol", event: { type: "reasoning_content", id: "r1", delta: marker } }, + { kind: "protocol", event: { type: "reasoning_end", id: "r1" } }, + { kind: "protocol", event: { type: "step_finish", finishReason: "stop" } }, + ]); + const parts = [ + { type: "text-delta", text: marker }, + { type: "reasoning-start", id: "r1" }, + { type: "reasoning-delta", id: "r1", delta: marker }, + { type: "reasoning-end", id: "r1" }, + { type: "tool-input-start", id: "call", toolName: "inspect" }, + { + type: "tool-input-available", + id: "call", + toolName: "inspect", + input: { text: marker }, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "call", + toolName: "inspect", + output: { items: [marker] }, + providerExecuted: true, + preliminary: false, + }, + { type: "finish", finishReason: "stop" }, + ]; + const apply = Reflect.apply; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const defineProperty = Object.defineProperty; + const originals: { target: object; key: PropertyKey; descriptor: PropertyDescriptor }[] = + []; + let observations = 0; + const replace = (target: object, key: PropertyKey, observeArgument = false) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if ( + apply(includes, stringify(observeArgument ? args[0] : this) ?? "", [marker]) + ) observations++; + const result = apply(descriptor.value, this, args); + if ( + target === Map.prototype && apply(includes, stringify(result) ?? "", [marker]) + ) observations++; + return result; + }, + }); + }; + let report: ReturnType | undefined; + let outcome: Awaited["outcome"]> | undefined; + try { + if (probe === "iterator") replace(Array.prototype, Symbol.iterator); + if (probe === "projection") { + for (const key of ["map", "filter", "every", "join"]) replace(Array.prototype, key); + } + if (probe === "map lookup") replace(Map.prototype, "get"); + if (probe === "reflection") { + replace(Object, "keys", true); + replace(Array, "isArray", true); + } + if (mode === "shadow") { + for (let index = 0; index < parts.length; index++) shadow.observePart(parts[index]); + report = shadow.compareLegacySnapshot(legacy); + } else { + const run = runStreamLifecycle({ provider }); + await Array.fromAsync(run.frames); + outcome = await run.outcome; + } + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + if (mode === "shadow") assertEquals(report, { count: 0, categories: [] }); + else { + assertEquals(outcome?.status, "completed"); + assertEquals(outcome?.snapshot.accumulatedText, marker); + assertEquals(outcome?.snapshot.reasoning[0]?.text, marker); + } + assertEquals(observations, 0); + }); + }); + } +} diff --git a/tests/integration/agent/message-provenance-intrinsics.test.ts b/tests/integration/agent/message-provenance-intrinsics.test.ts new file mode 100644 index 0000000000..152c9f09a6 --- /dev/null +++ b/tests/integration/agent/message-provenance-intrinsics.test.ts @@ -0,0 +1,62 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + hasSyntheticMessageId, + hasSyntheticMessageTimestamp, + hasUnchangedSyntheticMessageId, + hasUnchangedSyntheticMessageTimestamp, + normalizeInput, + propagateSyntheticMessageMarks, +} from "#veryfront/agent/runtime/input-utils.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private message provenance ${hooks ? "hooks" : "baseline"}`, () => { + it("preserves normalization marks without exposing messages through weak collections", () => { + const marker = "synthetic-private-normalized-message"; + const apply = Reflect.apply; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const defineProperty = Object.defineProperty; + const methods = [ + { target: WeakMap.prototype, key: "get" }, + { target: WeakMap.prototype, key: "set" }, + { target: WeakSet.prototype, key: "has" }, + { target: WeakSet.prototype, key: "add" }, + ].map((entry) => ({ + ...entry, + descriptor: Object.getOwnPropertyDescriptor(entry.target, entry.key)!, + })); + let observations = 0; + let generated, normalized, copied; + let idMarked, timeMarked, idUnchanged, timeUnchanged; + try { + if (hooks) { + for (const method of methods) { + defineProperty(method.target, method.key, { + ...method.descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (apply(includes, stringify(args[0]) ?? "", [marker])) observations++; + return apply(method.descriptor.value, this, args); + }, + }); + } + } + generated = normalizeInput(marker)[0]!; + normalized = normalizeInput([generated])[0]!; + copied = { ...normalized }; + propagateSyntheticMessageMarks(normalized, copied); + idMarked = hasSyntheticMessageId(copied); + timeMarked = hasSyntheticMessageTimestamp(copied); + idUnchanged = hasUnchangedSyntheticMessageId(copied, copied.id); + timeUnchanged = hasUnchangedSyntheticMessageTimestamp(copied, copied.timestamp); + } finally { + for (const method of methods) defineProperty(method.target, method.key, method.descriptor); + } + assertEquals(observations, 0); + assertEquals(normalized?.parts, [{ type: "text", text: marker }]); + assertEquals(copied, generated); + assertEquals([idMarked, timeMarked, idUnchanged, timeUnchanged], [true, true, true, true]); + }); + }); +} diff --git a/tests/integration/agent/remote-source-collection-intrinsics.test.ts b/tests/integration/agent/remote-source-collection-intrinsics.test.ts new file mode 100644 index 0000000000..ece1c9d88a --- /dev/null +++ b/tests/integration/agent/remote-source-collection-intrinsics.test.ts @@ -0,0 +1,109 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + bindRuntimeRemoteToolSourcesToCredentialOwner, + constrainRuntimeRemoteToolSources, + getRuntimeRemoteToolSources, + type RuntimeRemoteToolConfig, + VERYFRONT_API_MCP_SOURCE_ID, +} from "#veryfront/agent/runtime/mcp-server-tool-sources.ts"; +import { executeConfiguredTool, getAvailableTools } from "#veryfront/agent/runtime/tool-helpers.ts"; +import type { RemoteToolSource } from "#veryfront/tool"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private remote source collections ${hooks ? "hooks" : "baseline"}`, () => { + for (const explicit of [false, true]) { + it(`preserves source policy and runtime ceilings with explicit servers ${explicit}`, async () => { + let executions = 0; + const raw: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => + Promise.resolve([ + { name: "allowed", description: "Allowed tool", parameters: { type: "object" } }, + { name: "blocked", description: "Blocked tool", parameters: { type: "object" } }, + ]), + executeTool: () => { + executions++; + return Promise.resolve({ ok: true }); + }, + }; + const config = { + system: "Use the selected remote tools.", + ...(explicit + ? { + mcpServers: [{ kind: "veryfront-api" as const, toolPolicy: { allow: ["allowed"] } }], + } + : {}), + __vfRemoteToolSources: [raw], + } satisfies RuntimeRemoteToolConfig & Parameters[0]; + const apply = Reflect.apply; + const descriptor = Object.getOwnPropertyDescriptor; + const defineProperty = Object.defineProperty; + const originals = ["map", "filter", "some", Symbol.iterator].map((key) => ({ + key, + descriptor: descriptor(Array.prototype, key)!, + })); + let observations = 0; + let selected, constrained, bound, definitions, result; + try { + if (hooks) { + for (let index = 0; index < originals.length; index++) { + const original = originals[index]!; + defineProperty(Array.prototype, original.key, { + ...original.descriptor, + value: function (this: unknown[], ...args: unknown[]) { + for (let index = 0; index < this.length; index++) { + const value = descriptor(this, index)?.value; + if ( + value && typeof value === "object" && + descriptor(value, "id")?.value === raw.id && + typeof descriptor(value, "executeTool")?.value === "function" + ) observations++; + } + return apply(original.descriptor.value, this, args); + }, + }); + } + } + selected = getRuntimeRemoteToolSources(config)!; + constrained = constrainRuntimeRemoteToolSources(selected, ["allowed"])!; + bound = bindRuntimeRemoteToolSourcesToCredentialOwner(constrained, { + agentId: "synthetic-agent", + })!; + definitions = await getAvailableTools({ allowed: true }, { + includeIntegrationTools: false, + remoteToolSources: bound, + allowedRemoteToolNames: ["allowed"], + }); + result = await executeConfiguredTool( + "allowed", + {}, + { allowed: true }, + undefined, + ["allowed"], + bound, + ); + } finally { + for (let index = 0; index < originals.length; index++) { + const original = originals[index]!; + defineProperty(Array.prototype, original.key, original.descriptor); + } + } + assertEquals(observations, 0); + assertEquals(definitions?.map((definition) => definition.name), ["allowed"]); + assertEquals(result, { ok: true }); + if (explicit) { + assertThrows(() => selected![0]!.executeTool("blocked", {}), Error, "not allowed"); + } + assertThrows(() => bound![0]!.executeTool("blocked", {}), Error, "not allowed"); + await assertRejects( + () => executeConfiguredTool("allowed", {}, { allowed: true }, undefined, [], selected), + Error, + "not allowed", + ); + assertEquals(executions, 1); + }); + } + }); +} From b06f4eb45def44f24a42e008fa0c22e8b37427ed Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 00:00:58 +0200 Subject: [PATCH 182/194] fix(agent): traverse remote tool capabilities privately --- src/agent/runtime/mcp-server-tool-sources.ts | 39 +++++++--- ...emote-source-projection-intrinsics.test.ts | 72 +++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 tests/integration/agent/remote-source-projection-intrinsics.test.ts diff --git a/src/agent/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index 3f900594b2..636f3dfade 100644 --- a/src/agent/runtime/mcp-server-tool-sources.ts +++ b/src/agent/runtime/mcp-server-tool-sources.ts @@ -1,3 +1,10 @@ +import { + concatPrivateArrays, + filterPrivateArray, + flatMapPrivateArray, + mapPrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; import { createProjectScopedRemoteToolCatalog, createRemoteMCPToolSource, @@ -144,7 +151,7 @@ export function constrainRuntimeRemoteToolSources( const policy = { allow: [...new Set(allowedToolNames)] }; const sourcesToConstrain = sources ?? getActiveRuntimeRemoteToolSources() ?? []; - return sourcesToConstrain.map((source) => createMcpToolPolicySource(source, policy)); + return mapPrivateArray(sourcesToConstrain, (source) => createMcpToolPolicySource(source, policy)); } const REMOTE_TOOL_CREDENTIAL_CONTEXT_KEYS = [ @@ -194,7 +201,7 @@ export function bindRuntimeRemoteToolSourcesToCredentialOwner( return undefined; } - return sources.map((source) => ({ + return mapPrivateArray(sources, (source) => ({ id: source.id, listTools: (nestedContext) => source.listTools(withBoundRemoteToolContext(nestedContext, context, ["authToken"])), @@ -356,20 +363,28 @@ export function getRuntimeRemoteToolSources( } } const selectedInjectedSources = hasExplicitMcpServers - ? injectedSources.filter((source) => configuredFirstPartyServersBySourceId.has(source.id)) + ? filterPrivateArray( + injectedSources, + (source) => configuredFirstPartyServersBySourceId.has(source.id), + ) : injectedSources; - const policyWrappedInjectedSources = selectedInjectedSources.map((source) => { + const policyWrappedInjectedSources = mapPrivateArray(selectedInjectedSources, (source) => { const server = configuredFirstPartyServersBySourceId.get(source.id); const policy = server?.toolPolicy ?? (implicitToolNames.length > 0 ? { allow: implicitToolNames } : undefined); return createMcpToolPolicySource(source, policy); }); - const configuredSources = configuredServers.flatMap((server) => { + const configuredSources = flatMapPrivateArray(configuredServers, (server) => { if (isHttpMcpServerConfig(server)) { return [createMcpServerToolSource(server)]; } if (server.kind === "veryfront-api") { - if (injectedSources.some((source) => source.id === getFirstPartyMcpSourceId(server))) { + if ( + somePrivateArray( + injectedSources, + (source) => source.id === getFirstPartyMcpSourceId(server), + ) + ) { return []; } const source = createVeryfrontApiMcpServerToolSource( @@ -380,17 +395,19 @@ export function getRuntimeRemoteToolSources( return source ? [source] : []; } if (server.kind === "veryfront-studio") { - if (injectedSources.some((source) => source.id === getFirstPartyMcpSourceId(server))) { + if ( + somePrivateArray( + injectedSources, + (source) => source.id === getFirstPartyMcpSourceId(server), + ) + ) { return []; } requiresInjectedStudioMcpServerToolSource(server); } return []; }); - const remoteToolSources = [ - ...policyWrappedInjectedSources, - ...configuredSources, - ]; + const remoteToolSources = concatPrivateArrays(policyWrappedInjectedSources, configuredSources); if (remoteToolSources.length > 0) { return remoteToolSources; diff --git a/tests/integration/agent/remote-source-projection-intrinsics.test.ts b/tests/integration/agent/remote-source-projection-intrinsics.test.ts new file mode 100644 index 0000000000..baff363234 --- /dev/null +++ b/tests/integration/agent/remote-source-projection-intrinsics.test.ts @@ -0,0 +1,72 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + bindRuntimeRemoteToolSourcesToCredentialOwner, + constrainRuntimeRemoteToolSources, + getRuntimeRemoteToolSources, + type RuntimeRemoteToolConfig, + VERYFRONT_API_MCP_SOURCE_ID, +} from "#veryfront/agent/runtime/mcp-server-tool-sources.ts"; +import type { AgentConfig } from "#veryfront/agent/types.ts"; +import type { RemoteToolSource } from "#veryfront/tool"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const explicitPolicy of [false, true]) { + for (const hook of [undefined, "map", "filter", "some", Symbol.iterator] as const) { + describe(`private injected remote sources ${explicitPolicy ? "explicit" : "runtime"} ${String(hook ?? "baseline")}`, () => { + it("keeps raw execution capabilities private while preserving the selected policy", async () => { + const calls: string[] = []; + const source: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: async () => [], + executeTool: async (name) => { + calls.push(name); + return { success: true, data: "synthetic-result" }; + }, + }; + const config: AgentConfig & RuntimeRemoteToolConfig = { + id: "private-remote-agent", + system: "Synthetic remote tool test", + tools: true, + ...(explicitPolicy + ? { mcpServers: [{ kind: "veryfront-api", toolPolicy: { allow: ["allowed"] } }] } + : {}), + __vfRemoteToolSources: [source], + }; + const apply = Reflect.apply; + const defineProperty = Object.defineProperty; + const descriptor = hook === undefined + ? undefined + : Object.getOwnPropertyDescriptor(Array.prototype, hook)!; + let leaked: RemoteToolSource | undefined; + let selected: RemoteToolSource[] | undefined; + try { + if (hook !== undefined) { + defineProperty(Array.prototype, hook, { + ...descriptor, + value: function (this: unknown[], ...args: unknown[]) { + for (let i = 0; i < this.length; i++) { + if (this[i] === source) leaked = source; + } + return apply(descriptor!.value, this, args); + }, + }); + } + selected = getRuntimeRemoteToolSources(config); + selected = bindRuntimeRemoteToolSourcesToCredentialOwner(selected, { + agentId: "private-remote-agent", + }); + selected = constrainRuntimeRemoteToolSources(selected, ["allowed"]); + } finally { + if (hook !== undefined) defineProperty(Array.prototype, hook, descriptor!); + } + assertEquals(selected?.length, 1); + await selected![0]!.executeTool("allowed", {}); + await assertRejects(async () => await selected![0]!.executeTool("denied", {})); + if (leaked) await leaked.executeTool("denied", {}); + assertEquals(calls, ["allowed"]); + assertEquals(leaked, undefined); + }); + }); + } +} From 3bc60ab99c8bac8e4014ee5f764d1c355c46bb83 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 00:19:26 +0200 Subject: [PATCH 183/194] fix(agent): capture private runtime record checks --- src/agent/runtime/agent-runtime-step.ts | 7 +- src/agent/runtime/chat-stream-handler.ts | 3 +- src/agent/runtime/index.ts | 10 +- src/agent/runtime/tool-helpers.ts | 3 +- .../runtime-private-record-intrinsics.test.ts | 149 ++++++++++++++++++ 5 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 tests/integration/agent/runtime-private-record-intrinsics.test.ts diff --git a/src/agent/runtime/agent-runtime-step.ts b/src/agent/runtime/agent-runtime-step.ts index 3c56fa51df..502148675a 100644 --- a/src/agent/runtime/agent-runtime-step.ts +++ b/src/agent/runtime/agent-runtime-step.ts @@ -1,5 +1,6 @@ import { concatPrivateArrays, + everyPrivateArray, filterPrivateArray, flatMapPrivateArray, joinPrivateArray, @@ -41,6 +42,7 @@ import { getProviderToolProfile } from "./provider-tool-compat.ts"; import { resolveModelProviderOptionKey } from "./model-resolution.ts"; import { createProviderNativeToolExposureDefinitions } from "./provider-native-tool-inventory.ts"; +const ArrayIsArray = Array.isArray; const IntrinsicSet = Set; const IntrinsicReflectApply = Reflect.apply; const IntrinsicSetAdd = Set.prototype.add; @@ -217,7 +219,8 @@ function getTrustedAllowedSkillIds( input: PrepareAgentRuntimeStepInput, ): readonly string[] | undefined { const value = input.toolContextBase?.allowedSkillIds ?? input.runtimeContext?.allowedSkillIds; - return Array.isArray(value) && value.every((entry): entry is string => typeof entry === "string") + return ArrayIsArray(value) && + everyPrivateArray(value, (entry): entry is string => typeof entry === "string") ? value : undefined; } @@ -350,7 +353,7 @@ export async function prepareAgentRuntimeStep( ) : baseSystemPrompt; const systemPrompt = typeof baseSystemPrompt === "string" && - Array.isArray(instructionsWithToolInventory) + ArrayIsArray(instructionsWithToolInventory) ? flattenSystemInstructions(instructionsWithToolInventory) : instructionsWithToolInventory; diff --git a/src/agent/runtime/chat-stream-handler.ts b/src/agent/runtime/chat-stream-handler.ts index 9f219f33e5..52830cc898 100644 --- a/src/agent/runtime/chat-stream-handler.ts +++ b/src/agent/runtime/chat-stream-handler.ts @@ -81,6 +81,7 @@ import { compareStrings } from "#veryfront/utils/compare.ts"; import { isStatefulTurnCycleError } from "#veryfront/agent/runtime/stateful-turn-lineage.ts"; const hasOwn = Object.hasOwn; +const isArray = Array.isArray; const logger = serverLogger.component("agent"); const LOCAL_TOOL_COMMIT_GRACE_MS = 250; @@ -382,7 +383,7 @@ export interface ChatStreamCallbacks { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !isArray(value); } function normalizeToolInputString(input: unknown): string { diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 467e1eeb70..1bce6ffcda 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -389,6 +389,7 @@ const ObjectSetPrototypeOf = Object.setPrototypeOf; const ObjectHasOwn = Object.hasOwn; const ObjectIs = Object.is; const ObjectKeys = Object.keys; +const ObjectValues = Object.values; const ObjectPrototype = Object.prototype; const ReflectOwnKeys = Reflect.ownKeys; const WeakMapGet = IntrinsicWeakMap.prototype.get; @@ -1441,8 +1442,9 @@ function containsSubmittedFormInputExecutionResult(result: unknown, depth = 0): if ((normalized as { submitted?: unknown }).submitted === true) { return true; } - return Object.values(normalized).some((value) => - containsSubmittedFormInputExecutionResult(value, depth + 1) + return somePrivateArray( + ObjectValues(normalized), + (value) => containsSubmittedFormInputExecutionResult(value, depth + 1), ); } @@ -2194,7 +2196,7 @@ export class AgentRuntime { systemPrompt: AgentSystem, providerOptionKey: string | undefined, ): Promise { - const structuredSystem = Array.isArray(systemPrompt) ? systemPrompt : undefined; + const structuredSystem = ArrayIsArray(systemPrompt) ? systemPrompt : undefined; const refreshed: ResolvedRuntimeState | undefined = await this.config.resolveRuntimeState?.({ agentId: this.id, mode, @@ -4695,7 +4697,7 @@ async function reconcileSuppressedProviderMetadata( if ( reconciled === null || typeof reconciled !== "object" || - Array.isArray(reconciled) + ArrayIsArray(reconciled) ) { throw new TypeError( "Model runtime returned invalid provider metadata after suppressing a tool call", diff --git a/src/agent/runtime/tool-helpers.ts b/src/agent/runtime/tool-helpers.ts index fa214607b1..3c246a3b14 100644 --- a/src/agent/runtime/tool-helpers.ts +++ b/src/agent/runtime/tool-helpers.ts @@ -31,6 +31,7 @@ const logger = serverLogger.component("agent"); const intrinsicReflectApply = Reflect.apply; const intrinsicObjectEntries = Object.entries; const intrinsicHasOwn = Object.hasOwn; +const intrinsicIsArray = Array.isArray; const intrinsicArrayPush = Array.prototype.push; const intrinsicArrayIncludes = Array.prototype.includes; @@ -66,7 +67,7 @@ export function parseToolArgs( const parsed = typeof rawArgs === "string" ? privateJsonParse(rawArgs) : rawArgs; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!parsed || typeof parsed !== "object" || intrinsicIsArray(parsed)) { return { args: {}, error: "Tool call arguments must be a JSON object" }; } diff --git a/tests/integration/agent/runtime-private-record-intrinsics.test.ts b/tests/integration/agent/runtime-private-record-intrinsics.test.ts new file mode 100644 index 0000000000..8576393ad2 --- /dev/null +++ b/tests/integration/agent/runtime-private-record-intrinsics.test.ts @@ -0,0 +1,149 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { parseToolArgs } from "#veryfront/agent/runtime/tool-helpers.ts"; +import { createStreamState, processStream } from "#veryfront/agent/runtime/chat-stream-handler.ts"; +import { + createMockResult, + createSSECollector, +} from "#veryfront/agent/runtime/chat-stream-handler.test-helpers.ts"; +import { tool } from "#veryfront/tool"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const hooks of [false, true]) { + describe(`private runtime records ${hooks ? "hooks" : "baseline"}`, () => { + for (const mode of ["generate", "stream"] as const) { + for (const scenario of ["system prompt", "submitted form"] as const) { + it(`protects ${scenario} through ${mode}`, async () => { + const marker = "synthetic-private-runtime-record"; + const form = scenario === "submitted form"; + const model = scriptedModel( + form + ? [ + { toolCalls: [{ id: "form", name: "form_input", input: {} }] }, + { text: "Complete" }, + ] + : [{ text: "Complete" }], + { only: mode }, + ); + let executions = 0; + let submittedObserved = false; + const runtime = createEphemeralAgentWithRuntimeOptions({ + model: "veryfront-cloud/openai/gpt-5.4", + system: form ? "Use the form result." : marker, + skills: false, + maxSteps: 2, + resolveRuntimeState: ({ context }) => { + if (context?.hasSubmittedFormInputResult === true) submittedObserved = true; + return undefined; + }, + ...(form + ? { + tools: { + form_input: tool({ + id: "form_input", + description: "Collect a synthetic response", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + executions++; + return JSON.stringify({ + envelope: { submitted: true, values: { text: marker } }, + }); + }, + }), + }, + } + : {}), + }, { resolveModelRuntime: () => model }); + const apply = Reflect.apply; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const defineProperty = Object.defineProperty; + const originals = (form + ? [{ target: Object, key: "values", receiver: false }, { + target: Array.prototype, + key: "some", + receiver: true, + }] + : [{ target: Array, key: "isArray", receiver: false }]).map((entry) => ({ + ...entry, + descriptor: Object.getOwnPropertyDescriptor(entry.target, entry.key)!, + })); + let observations = 0; + let output = ""; + try { + if (hooks) { + for (let index = 0; index < originals.length; index++) { + const original = originals[index]!; + defineProperty(original.target, original.key, { + ...original.descriptor, + value: function (this: unknown, ...args: unknown[]) { + if ( + apply(includes, stringify(original.receiver ? this : args[0]) ?? "", [marker]) + ) { + observations++; + } + return apply(original.descriptor.value, this, args); + }, + }); + } + } + output = mode === "stream" + ? await (await runtime.stream({ input: "Complete the task" })).toDataStreamResponse() + .text() + : (await runtime.generate({ input: "Complete the task" })).text; + } finally { + for (let index = 0; index < originals.length; index++) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertStringIncludes(output, "Complete"); + assertEquals(model.callCount, form ? 2 : 1); + assertEquals(executions, form ? 1 : 0); + assertEquals(submittedObserved, form); + assertEquals(observations, 0); + }); + } + } + it("parses serialized tool arguments without exposing the parsed record", async () => { + const marker = "synthetic-private-parsed-arguments"; + const serialized = JSON.stringify({ query: marker }); + const state = createStreamState(); + const { events, controller, encoder } = createSSECollector(); + const result = createMockResult([ + { type: "tool-input-start", id: "call", toolName: "inspect" }, + { type: "tool-call", toolCallId: "call", toolName: "inspect", input: serialized }, + { type: "finish", finishReason: "tool-calls" }, + ]); + const isArray = Array.isArray; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const apply = Reflect.apply; + let observations = 0; + let parsed; + let rejected; + try { + if (hooks) { + Array.isArray = ((value: unknown) => { + if (apply(includes, stringify(value) ?? "", [marker])) observations++; + return isArray(value); + }) as typeof Array.isArray; + } + await processStream(result, state, controller, encoder, "text", undefined); + parsed = parseToolArgs(serialized); + rejected = parseToolArgs("[]"); + } finally { + if (hooks) Array.isArray = isArray; + } + assertEquals(parsed, { args: { query: marker } }); + assertEquals(rejected?.error, "Tool call arguments must be a JSON object"); + assertEquals(events.find((event) => event.type === "tool-input-available")?.input, { + query: marker, + }); + assertEquals(observations, 0); + }); + }); +} From 0d1a25c79f38a0751fa81e4dd123aec3660b75bd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 00:29:28 +0200 Subject: [PATCH 184/194] fix(agent): reject malformed managed run routes with 400 --- docs/guides/agent-service-runtime.md | 4 ++ src/agent/service/managed-node-broker.ts | 31 +++++++---- .../agent/managed-node-broker.test.ts | 54 +++++++++++++++++++ 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 126f26f32d..61e53c1ba7 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -471,6 +471,10 @@ pool, and a readiness check explicitly. It preserves `/liveness` and work to retire. This server adapter does not configure product policy, registration, credentials, or executor images. +Managed run routes return HTTP 400 with `BROKER_INGRESS_TARGET_MISMATCH` when +a run ID contains malformed URL encoding or an encoded slash. Valid encoded +run IDs are decoded before the handler receives them. + ## Verify it worked Start the service entrypoint and call the run route directly. The default diff --git a/src/agent/service/managed-node-broker.ts b/src/agent/service/managed-node-broker.ts index a5b4806fc6..72a0842a78 100644 --- a/src/agent/service/managed-node-broker.ts +++ b/src/agent/service/managed-node-broker.ts @@ -54,6 +54,9 @@ export async function startNodeManagedAgentBroker(options: { async handle(request) { const url = new URL(request.url); const route = resolveRoute(request.method, url.pathname); + if (route?.kind === "invalid") { + return Response.json({ errorCode: "BROKER_INGRESS_TARGET_MISMATCH" }, { status: 400 }); + } if (route?.kind === "liveness") return new Response("OK"); if (route?.kind === "ready") { const ready = !shuttingDown && await options.readiness(); @@ -98,9 +101,11 @@ export async function startNodeManagedAgentBroker(options: { return server; } +type RunRoute = { kind: "signedStream" | "cancel" | "resume"; runId: string }; + type Route = - | { kind: "signedStream" | "cancel" | "resume"; runId: string } - | { kind: "durableStart" | "agUi" | "liveness" | "ready" }; + | RunRoute + | { kind: "durableStart" | "agUi" | "liveness" | "ready" | "invalid" }; function resolveRoute(method: string, pathname: string): Route | undefined { if (method === "GET" && pathname === "/liveness") return { kind: "liveness" }; @@ -108,26 +113,30 @@ function resolveRoute(method: string, pathname: string): Route | undefined { if (method === "POST" && pathname === "/api/runs") return { kind: "durableStart" }; if (method === "POST" && pathname === "/api/ag-ui") return { kind: "agUi" }; const signed = /^\/api\/control-plane\/runs\/([^/]+)\/stream$/u.exec(pathname); - if (method === "POST" && signed) return { kind: "signedStream", runId: decodeRunId(signed[1]!) }; + if (method === "POST" && signed) return decodeRunRoute("signedStream", signed[1]!); const resume = /^\/api\/runs\/([^/]+)\/resume$/u.exec(pathname); - if (method === "POST" && resume) return { kind: "resume", runId: decodeRunId(resume[1]!) }; + if (method === "POST" && resume) return decodeRunRoute("resume", resume[1]!); const controlResume = /^\/api\/control-plane\/runs\/([^/]+)\/resume$/u.exec(pathname); if (method === "POST" && controlResume) { - return { kind: "resume", runId: decodeRunId(controlResume[1]!) }; + return decodeRunRoute("resume", controlResume[1]!); } const cancel = /^\/api\/runs\/([^/]+)$/u.exec(pathname); - if (method === "DELETE" && cancel) return { kind: "cancel", runId: decodeRunId(cancel[1]!) }; + if (method === "DELETE" && cancel) return decodeRunRoute("cancel", cancel[1]!); const controlCancel = /^\/api\/control-plane\/runs\/([^/]+)$/u.exec(pathname); if (method === "DELETE" && controlCancel) { - return { kind: "cancel", runId: decodeRunId(controlCancel[1]!) }; + return decodeRunRoute("cancel", controlCancel[1]!); } return undefined; } -function decodeRunId(value: string): string { - const decoded = decodeURIComponent(value); - if (!decoded || decoded.includes("/")) throw new TypeError("Invalid managed broker run id"); - return decoded; +function decodeRunRoute(kind: RunRoute["kind"], value: string): Route { + let runId: string; + try { + runId = decodeURIComponent(value); + } catch { + return { kind: "invalid" }; + } + return runId && !runId.includes("/") ? { kind, runId } : { kind: "invalid" }; } function validateOptions(options: { diff --git a/tests/integration/agent/managed-node-broker.test.ts b/tests/integration/agent/managed-node-broker.test.ts index 3039e9a8bb..8d93244c46 100644 --- a/tests/integration/agent/managed-node-broker.test.ts +++ b/tests/integration/agent/managed-node-broker.test.ts @@ -68,6 +68,60 @@ describe("managed node broker", () => { } }); + it("rejects malformed run IDs before dispatching managed handlers", async () => { + const runIds: string[] = []; + const handler = { + handle(_request: Request, input: { runId?: string }) { + runIds.push(input.runId!); + return new Response("handled"); + }, + }; + const server = await startNodeManagedAgentBroker({ + port: 0, + bindAddress: "127.0.0.1", + signals: [], + readiness: () => true, + broker: { + shutdown: () => Promise.resolve(), + closed: Promise.resolve(), + settled: Promise.resolve(), + }, + handlers: { + signedStream: handler, + durableStart: handler, + agUi: handler, + cancel: handler, + resume: handler, + }, + }); + try { + const routes = [ + ["POST", "/api/control-plane/runs/", "/stream"], + ["POST", "/api/runs/", "/resume"], + ["POST", "/api/control-plane/runs/", "/resume"], + ["DELETE", "/api/runs/", ""], + ["DELETE", "/api/control-plane/runs/", ""], + ] as const; + for (const [method, prefix, suffix] of routes) { + for (const malformed of ["%", "%GG", "%E0%A4%A", "%2F"]) { + const response = await fetch(`${server.url}${prefix}${malformed}${suffix}`, { method }); + const body = await response.text(); + assertEquals(response.status, 400); + assertEquals(JSON.parse(body), { errorCode: "BROKER_INGRESS_TARGET_MISMATCH" }); + } + } + assertEquals(runIds, []); + for (const [method, prefix, suffix] of routes) { + const response = await fetch(`${server.url}${prefix}%72un%2D1${suffix}`, { method }); + assertEquals(await response.text(), "handled"); + assertEquals(response.status, 200); + } + assertEquals(runIds, ["run-1", "run-1", "run-1", "run-1", "run-1"]); + } finally { + await server.stop(); + } + }); + it("stops admission before joining handler and broker retirement", async () => { const events: string[] = []; const retirement = Promise.withResolvers(); From 8c66b7b7685cc09d2cf65346e9af7af87529d888 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 00:47:35 +0200 Subject: [PATCH 185/194] fix(agent): protect post-write tool visibility from project hooks --- src/agent/runtime/index.ts | 48 +++++--- src/agent/runtime/tool-helpers.ts | 17 +-- ...ent-write-guard-private-intrinsics.test.ts | 106 ++++++++++++++++++ 3 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 tests/integration/agent/agent-write-guard-private-intrinsics.test.ts diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 1bce6ffcda..b8008a3a87 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -35,6 +35,7 @@ import { } from "#veryfront/security/private-stream.ts"; import { chainPrivatePromise, createPrivateDeferred } from "#veryfront/security/private-promise.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { createPrivateMap } from "#veryfront/security/private-map.ts"; import { enterSerializedTurn, @@ -1355,7 +1356,10 @@ function getResponseFinishReason(response: AgentResponse): string | undefined { return typeof finishReason === "string" && finishReason.length > 0 ? finishReason : undefined; } -const AGENT_WRITE_FINAL_RESPONSE_EXCLUDED_TOOL_NAMES = new Set([ +const agentWriteArraySort = Array.prototype.sort; +const agentWriteApply = Reflect.apply; + +const AGENT_WRITE_FINAL_RESPONSE_EXCLUDED_TOOL_NAMES = createPrivateSet([ "create_agent", "update_agent", ]); @@ -1365,8 +1369,9 @@ function shouldHideProjectToolAfterAgentWriteSuccess(toolName: string): boolean } function didReloadProjectAgentWriteTool(result: ToolSearchResult): boolean { - return result.matches.some((match) => - match.status === "loaded" && shouldHideProjectToolAfterAgentWriteSuccess(match.name) + return somePrivateArray( + result.matches, + (match) => match.status === "loaded" && shouldHideProjectToolAfterAgentWriteSuccess(match.name), ); } @@ -1385,28 +1390,33 @@ function applyAgentWriteFinalResponseGuard( } } if (options.reloadable) { - const guardedTools = plan.authorized.filter((tool) => !keep(tool)); - const visible = plan.visible.filter(keep); - const deferredByName = new Map( - [...plan.deferred, ...guardedTools].map((tool) => [tool.name, tool]), - ); + const guardedTools = filterPrivateArray(plan.authorized, (tool) => !keep(tool)); + const visible = filterPrivateArray(plan.visible, keep); + const deferredByName = createPrivateMap(); + const deferredTools = concatPrivateArrays(plan.deferred, guardedTools); + for (let index = 0; index < deferredTools.length; index++) { + const tool = deferredTools[index]!; + deferredByName.set(tool.name, tool); + } if ( guardedTools.length > 0 && - !visible.some((tool) => tool.name === TOOL_SEARCH_TOOL_NAME) + !somePrivateArray(visible, (tool) => tool.name === TOOL_SEARCH_TOOL_NAME) ) { pushPrivateArray(visible, createToolSearchDefinition()); } return { ...plan, - visible: visible.sort(compareToolNames), - deferred: [...deferredByName.values()].sort(compareToolNames), + visible: agentWriteApply(agentWriteArraySort, visible, [compareToolNames]), + deferred: agentWriteApply(agentWriteArraySort, [...deferredByName.values()], [ + compareToolNames, + ]), }; } return { ...plan, - authorized: plan.authorized.filter(keep), - visible: plan.visible.filter(keep), - deferred: plan.deferred.filter(keep), + authorized: filterPrivateArray(plan.authorized, keep), + visible: filterPrivateArray(plan.visible, keep), + deferred: filterPrivateArray(plan.deferred, keep), }; } @@ -2888,8 +2898,9 @@ export class AgentRuntime { currentSystemPrompt, runtimeTools, agentWriteFinalResponseToolGuardEnabled - ? effectiveToolExposurePlan.deferred.filter((tool) => - shouldHideProjectToolAfterAgentWriteSuccess(tool.name) + ? filterPrivateArray( + effectiveToolExposurePlan.deferred, + (tool) => shouldHideProjectToolAfterAgentWriteSuccess(tool.name), ) : [], ), @@ -3542,8 +3553,9 @@ export class AgentRuntime { currentSystemPrompt, runtimeTools, agentWriteFinalResponseToolGuardEnabled - ? effectiveToolExposurePlan.deferred.filter((tool) => - shouldHideProjectToolAfterAgentWriteSuccess(tool.name) + ? filterPrivateArray( + effectiveToolExposurePlan.deferred, + (tool) => shouldHideProjectToolAfterAgentWriteSuccess(tool.name), ) : [], ), diff --git a/src/agent/runtime/tool-helpers.ts b/src/agent/runtime/tool-helpers.ts index 3c246a3b14..8615015a15 100644 --- a/src/agent/runtime/tool-helpers.ts +++ b/src/agent/runtime/tool-helpers.ts @@ -1,3 +1,6 @@ +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; import { stripLeadingEmptyObjectPlaceholder } from "../streaming/tool-input.ts"; /** * Tool Helpers @@ -171,7 +174,7 @@ async function getRemoteToolDefinitions(options?: { }): Promise { const remoteToolContext = options?.remoteToolContext; const definitions: ToolDefinition[] = []; - const seenToolNames = new Set(); + const seenToolNames = createPrivateSet(); const addDefinition = (definition: ToolDefinition): void => { if (seenToolNames.has(definition.name)) { @@ -193,8 +196,8 @@ async function getRemoteToolDefinitions(options?: { const source = sources[index]!; try { const sourceDefs = await source.listTools(remoteToolContext); - for (const def of sourceDefs) { - addDefinition(def); + for (let index = 0; index < sourceDefs.length; index++) { + if (intrinsicHasOwn(sourceDefs, index)) addDefinition(sourceDefs[index]!); } } catch (error) { logger.warn("Failed to fetch remote tool definitions from source", { @@ -415,7 +418,7 @@ function appendForwardedToolDefinitions( allowedNames: string[] | undefined, ): void { if (!forwarded?.length) return; - const existing = new Set(remoteDefs.map((def) => def.name)); + const existing = createPrivateSet(mapPrivateArray(remoteDefs, (def) => def.name)); for (const def of forwarded) { if (existing.has(def.name)) continue; if (allowedNames && !intrinsicIncludes(allowedNames, def.name)) continue; @@ -499,10 +502,10 @@ export async function getAvailableTools( options?.allowedRemoteToolNames, ); } - const remoteToolNames = new Set(remoteDefs.map((def) => def.name)); - const explicitlyRequestedRemoteToolNames = new Set(); + const remoteToolNames = createPrivateSet(mapPrivateArray(remoteDefs, (def) => def.name)); + const explicitlyRequestedRemoteToolNames = createPrivateSet(); const unresolvedConfiguredToolNames: string[] = []; - const configuredAuthorizationToolNames = new Map(); + const configuredAuthorizationToolNames = createPrivateMap(); const configuredEntries = intrinsicReflectApply(intrinsicObjectEntries, Object, [ toolsConfig, diff --git a/tests/integration/agent/agent-write-guard-private-intrinsics.test.ts b/tests/integration/agent/agent-write-guard-private-intrinsics.test.ts new file mode 100644 index 0000000000..9a5c2d768a --- /dev/null +++ b/tests/integration/agent/agent-write-guard-private-intrinsics.test.ts @@ -0,0 +1,106 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createEphemeralAgentWithRuntimeOptions } from "#veryfront/agent/factory.ts"; +import { + scriptedModel, + type ScriptedTurnScript, +} from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import type { RuntimeRemoteToolConfig } from "#veryfront/agent/runtime/mcp-server-tool-sources.ts"; +import type { RuntimeToolFilterConfig } from "#veryfront/agent/runtime/runtime-tool-config.ts"; +import type { AgentConfig } from "#veryfront/agent/types.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +for (const mode of ["generate", "stream"] as const) { + for (const loading of ["eager", "deferred"] as const) { + describe(`private agent write guard ${mode} ${loading}`, () => { + for (const name of ["create_agent", "update_agent"]) { + for (const probe of ["baseline", "arrays", "filter bypass", "set bypass"]) { + it(`${name} stays hidden after success with ${probe}`, async () => { + const turns: ScriptedTurnScript[] = []; + if (loading === "deferred") { + turns.push({ + toolCalls: [{ id: "search", name: "tool_search", input: { query: name } }], + }); + } + turns.push({ toolCalls: [{ id: "write", name, input: { id: "synthetic-agent" } }] }); + turns.push({ text: "Complete" }); + const model = scriptedModel(turns, { only: mode }); + let executions = 0; + const config: AgentConfig & RuntimeRemoteToolConfig & RuntimeToolFilterConfig = { + model: "veryfront-cloud/openai/gpt-5.4", + system: "Synthetic instructions", + skills: false, + maxSteps: 4, + __vfToolLoadingMode: loading, + tools: { [name]: true }, + __vfRemoteToolSources: [{ + id: "synthetic-remote-source", + listTools: async () => [{ + name, + description: "Write a project agent", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }], + executeTool: async () => { + executions++; + return { id: "synthetic-agent", source_path: "agents/synthetic-agent.ts" }; + }, + }], + }; + const runtime = createEphemeralAgentWithRuntimeOptions(config, { + resolveModelRuntime: () => model, + }); + const apply = Reflect.apply; + const stringify = JSON.stringify; + const includes = String.prototype.includes; + const defineProperty = Object.defineProperty; + const originals: { + target: object; + key: PropertyKey; + descriptor: PropertyDescriptor; + }[] = []; + let observations = 0; + const replace = (target: object, key: PropertyKey) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key)!; + originals.push({ target, key, descriptor }); + defineProperty(target, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + if (executions > 0) { + if (probe === "set bypass" && args[0] === name) return false; + if (apply(includes, stringify(this) ?? "", [`"name":"${name}"`])) { + observations++; + if (probe === "filter bypass") return this; + } + } + return apply(descriptor.value, this, args); + }, + }); + }; + let output = ""; + try { + if (probe === "arrays") { + for (const key of ["filter", "map", "some", "sort", Symbol.iterator]) { + replace(Array.prototype, key); + } + } else if (probe === "filter bypass") replace(Array.prototype, "filter"); + else if (probe === "set bypass") replace(Set.prototype, "has"); + output = mode === "stream" + ? await (await runtime.stream({ input: "Write the agent" })).toDataStreamResponse() + .text() + : (await runtime.generate({ input: "Write the agent" })).text; + } finally { + for (let index = originals.length - 1; index >= 0; index--) { + const original = originals[index]!; + defineProperty(original.target, original.key, original.descriptor); + } + } + assertEquals(executions, 1); + assertStringIncludes(output, "Complete"); + assertEquals(model.toolNames(model.callCount - 1).includes(name), false); + assertEquals(observations, 0); + }); + } + } + }); + } +} From a51e6023e2ed2a0708f21c799d0e9c6422315deb Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 00:50:38 +0200 Subject: [PATCH 186/194] fix(agent): validate and decode broker run routes consistently --- docs/guides/agent-service-runtime.md | 6 ++- src/agent/service/broker-ingress.test.ts | 11 ++++++ src/agent/service/broker-ingress.ts | 4 +- src/agent/service/broker-run-route.ts | 19 ++++++++++ .../service/managed-broker-handler.test.ts | 37 +++++++++++++++++-- src/agent/service/managed-broker-handler.ts | 7 ++-- src/agent/service/managed-node-broker.ts | 10 ++--- .../agent/managed-node-broker.test.ts | 15 +++++++- 8 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 src/agent/service/broker-run-route.ts diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 61e53c1ba7..c5da04a2df 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -472,8 +472,10 @@ work to retire. This server adapter does not configure product policy, registration, credentials, or executor images. Managed run routes return HTTP 400 with `BROKER_INGRESS_TARGET_MISMATCH` when -a run ID contains malformed URL encoding or an encoded slash. Valid encoded -run IDs are decoded before the handler receives them. +a run ID contains malformed URL encoding or fails the canonical run ID schema +after decoding. Run IDs contain 1 to 128 ASCII letters, digits, underscores, +or hyphens. Valid encoded run IDs are decoded before the handler receives them. +Signed stream requests must sign the original encoded request path. ## Verify it worked diff --git a/src/agent/service/broker-ingress.test.ts b/src/agent/service/broker-ingress.test.ts index 5395cf2283..623889e193 100644 --- a/src/agent/service/broker-ingress.test.ts +++ b/src/agent/service/broker-ingress.test.ts @@ -82,6 +82,17 @@ function options(publicKeyPem: string) { } describe("managed broker ingress", () => { + it("accepts a canonical run ID encoded in the signed request path", async () => { + const encodedPath = "/api/control-plane/runs/%72un%2D1/stream"; + const signed = await signedRequest(invocation(), { requestPath: encodedPath }); + const encoded = new Request(`https://broker.test${encodedPath}`, { + method: "POST", + headers: signed.request.headers, + body: await signed.request.text(), + }); + const result = await parseBrokerRuntimeAgentIngress(encoded, options(signed.publicKeyPem)); + assertEquals(result.executor.run.runId, "run-1"); + }); it("verifies the exact body and produces disjoint private authority and executor data", async () => { const signed = await signedRequest(); const result = await parseBrokerRuntimeAgentIngress( diff --git a/src/agent/service/broker-ingress.ts b/src/agent/service/broker-ingress.ts index c7c58d9600..e4e1bf43dc 100644 --- a/src/agent/service/broker-ingress.ts +++ b/src/agent/service/broker-ingress.ts @@ -1,3 +1,4 @@ +import { parseBrokerSignedRunPath } from "./broker-run-route.ts"; import { containsBrokerCredential } from "#veryfront/agent/service/broker-credentials.ts"; import type { ControlPlaneClaims, ControlPlaneSurface } from "#veryfront/channels/control-plane.ts"; import { @@ -135,9 +136,8 @@ export async function parseBrokerRuntimeAgentIngress( options: BrokerRuntimeAgentIngressOptions, ): Promise> { const expectedRunId = getRuntimeAgentRunIdSchema().parse(options.expectedRunId); - const expectedPath = `/api/control-plane/runs/${expectedRunId}/stream`; const actualPath = new URL(request.url).pathname; - if (request.method !== "POST" || actualPath !== expectedPath) { + if (request.method !== "POST" || parseBrokerSignedRunPath(actualPath) !== expectedRunId) { throw new BrokerIngressError(400, "BROKER_INGRESS_TARGET_MISMATCH"); } const inboundAuthorization = request.headers.get("authorization"); diff --git a/src/agent/service/broker-run-route.ts b/src/agent/service/broker-run-route.ts new file mode 100644 index 0000000000..c5caa09733 --- /dev/null +++ b/src/agent/service/broker-run-route.ts @@ -0,0 +1,19 @@ +import { getRuntimeAgentRunIdSchema } from "../runtime/agent-invocation-contract.ts"; + +/** Decode one path segment and enforce the canonical runtime run ID contract. */ +export function decodeBrokerRunId(value: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + return null; + } + const parsed = getRuntimeAgentRunIdSchema().safeParse(decoded); + return parsed.success ? parsed.data : null; +} + +/** Read the signed stream target without changing the path used for signature verification. */ +export function parseBrokerSignedRunPath(pathname: string): string | null { + const match = /^\/api\/control-plane\/runs\/([^/]+)\/stream$/u.exec(pathname); + return match ? decodeBrokerRunId(match[1]!) : null; +} diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index 49837e3f8c..55a0dd4f2b 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -15,7 +15,7 @@ const projectId = "00000000-0000-4000-8000-000000000005"; const userId = "00000000-0000-4000-8000-000000000006"; const path = "/api/control-plane/runs/run-1/stream"; -async function request(signal?: AbortSignal) { +async function request(signal?: AbortSignal, requestPath = path) { const body = JSON.stringify({ run: { agentServiceId: "service-1", @@ -37,11 +37,11 @@ async function request(signal?: AbortSignal) { audience: "demo-project", projectId, requestId: "run-1", - requestPath: path, + requestPath, }); return { publicKeyPem: signed.publicKeyPem, - request: new Request(`https://broker.test${path}`, { + request: new Request(`https://broker.test${requestPath}`, { method: "POST", headers: { authorization: "Bearer broker-token", @@ -145,6 +145,7 @@ async function handler( mode: "detached" | "sse", options: { signal?: AbortSignal; + requestPath?: string; failStart?: boolean; admitBeforeFailure?: boolean; waitForPrepare?: boolean; @@ -158,7 +159,7 @@ async function handler( startError?: Error; } = {}, ) { - const first = await request(options.signal); + const first = await request(options.signal, options.requestPath); const fixture = runtimeFixture( options.streamFailure, options.terminalChunk, @@ -266,6 +267,34 @@ async function handler( } describe("managed broker handler", () => { + it("accepts encoded run IDs while verifying the original signed path", async () => { + const encodedPath = "/api/control-plane/runs/%72un%2D1/stream"; + const f = await handler("detached", { requestPath: encodedPath }); + try { + const response = await f.managed.handle(f.first.request); + assertEquals(response.status, 202); + assertEquals(f.brokerStarts, 1); + } finally { + f.fixture.release(); + await f.managed.close(); + } + const canonical = await handler("detached"); + try { + const original = canonical.first.request; + const changedPath = new Request(`https://broker.test${encodedPath}`, { + method: original.method, + headers: original.headers, + body: await original.text(), + }); + const response = await canonical.managed.handle(changedPath); + assertEquals(response.status, 401); + assertEquals(await response.json(), { errorCode: "BROKER_INGRESS_AUTH_INVALID" }); + assertEquals(canonical.brokerStarts, 0); + } finally { + canonical.fixture.release(); + await canonical.managed.close(); + } + }); it("preserves typed executor failure statuses on both response modes", async () => { for (const mode of ["detached", "sse"] as const) { const f = await handler(mode, { diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index aceb4364b4..49cbc772a5 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -25,7 +25,7 @@ import { parseBrokerRuntimeAgentIngress, } from "./broker-ingress.ts"; -const runPath = /^\/api\/control-plane\/runs\/([A-Za-z0-9_-]{1,128})\/stream$/u; +import { parseBrokerSignedRunPath } from "./broker-run-route.ts"; /** Trusted executor admission boundary with actual settlement notification. */ export interface ManagedExecutorStarter { @@ -62,14 +62,13 @@ export function createManagedBrokerHandler(options: { let closed = false; async function handle(request: Request): Promise { - const match = runPath.exec(new URL(request.url).pathname); - if (request.method !== "POST" || !match) { + const runId = parseBrokerSignedRunPath(new URL(request.url).pathname); + if (request.method !== "POST" || runId === null) { return Response.json({ errorCode: "BROKER_INGRESS_TARGET_MISMATCH" }, { status: 400 }); } if (closed || options.signal?.aborted) { return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); } - const runId = match[1]!; try { const signal = AbortSignal.any([ request.signal, diff --git a/src/agent/service/managed-node-broker.ts b/src/agent/service/managed-node-broker.ts index 72a0842a78..b03bf87bea 100644 --- a/src/agent/service/managed-node-broker.ts +++ b/src/agent/service/managed-node-broker.ts @@ -1,3 +1,4 @@ +import { decodeBrokerRunId } from "./broker-run-route.ts"; import { createVeryfrontServer, type NodeVeryfrontServiceServer, @@ -130,13 +131,8 @@ function resolveRoute(method: string, pathname: string): Route | undefined { } function decodeRunRoute(kind: RunRoute["kind"], value: string): Route { - let runId: string; - try { - runId = decodeURIComponent(value); - } catch { - return { kind: "invalid" }; - } - return runId && !runId.includes("/") ? { kind, runId } : { kind: "invalid" }; + const runId = decodeBrokerRunId(value); + return runId === null ? { kind: "invalid" } : { kind, runId }; } function validateOptions(options: { diff --git a/tests/integration/agent/managed-node-broker.test.ts b/tests/integration/agent/managed-node-broker.test.ts index 8d93244c46..85e9ca607f 100644 --- a/tests/integration/agent/managed-node-broker.test.ts +++ b/tests/integration/agent/managed-node-broker.test.ts @@ -103,7 +103,20 @@ describe("managed node broker", () => { ["DELETE", "/api/control-plane/runs/", ""], ] as const; for (const [method, prefix, suffix] of routes) { - for (const malformed of ["%", "%GG", "%E0%A4%A", "%2F"]) { + for ( + const malformed of [ + "%", + "%GG", + "%E0%A4%A", + "%2F", + "%00", + "%5C", + "%20", + "%C3%A9", + "%252F", + "a".repeat(129), + ] + ) { const response = await fetch(`${server.url}${prefix}${malformed}${suffix}`, { method }); const body = await response.text(); assertEquals(response.status, 400); From b24f108d8a6ae23b053d90352a47b8c3ffe48841 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 07:59:36 +0200 Subject: [PATCH 187/194] fix(agent): reject escaped broker credentials and classify shutdowns --- src/agent/service/broker-credentials.ts | 15 ++++++++-- src/agent/service/broker-ingress.test.ts | 27 ++++++++++++++++- .../service/managed-broker-handler.test.ts | 30 +++++++++++++++++++ src/agent/service/managed-broker-handler.ts | 2 +- .../service/managed-hosted-ingress.test.ts | 23 ++++++++++---- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/agent/service/broker-credentials.ts b/src/agent/service/broker-credentials.ts index 16ef7c52a3..68bd267230 100644 --- a/src/agent/service/broker-credentials.ts +++ b/src/agent/service/broker-credentials.ts @@ -1,4 +1,4 @@ -/** Detect known broker credentials in bounded application strings and property names. */ +/** Detect known broker credentials in literal or URI-decoded strings and property names. */ export function containsBrokerCredential( value: unknown, credentials: readonly (string | null | undefined)[], @@ -13,11 +13,20 @@ export function containsBrokerCredential( } function containsString(value: unknown, expected: string): boolean { - if (typeof value === "string") return value.includes(expected); + if (typeof value === "string") return containsCredentialText(value, expected); if (!value || typeof value !== "object") return false; return Array.isArray(value) ? value.some((entry) => containsString(entry, expected)) : Object.entries(value).some(([key, entry]) => - key.includes(expected) || containsString(entry, expected) + containsCredentialText(key, expected) || containsString(entry, expected) ); } + +function containsCredentialText(value: string, expected: string): boolean { + if (value.includes(expected)) return true; + try { + return decodeURIComponent(value).includes(expected); + } catch { + return false; + } +} diff --git a/src/agent/service/broker-ingress.test.ts b/src/agent/service/broker-ingress.test.ts index 623889e193..5c83eb0c2c 100644 --- a/src/agent/service/broker-ingress.test.ts +++ b/src/agent/service/broker-ingress.test.ts @@ -155,13 +155,38 @@ describe("managed broker ingress", () => { } }); + for ( + const [placement, forwardedProps] of [ + ["attachment URL", { + attachments: [{ url: "https://files.test/document?token=api%2Dauth%2Dtoken&download=1" }], + }], + ["property name", { attachments: [{ "result-inference%2Dtoken-metadata": "value" }] }], + ["Bearer attachment URL", { + attachments: [{ url: "https://files.test/document?token=Bearer%20broker%2Dtoken" }], + }], + ] as const + ) { + it(`rejects URI-escaped credentials in an ${placement}`, async () => { + const signed = await signedRequest(invocation({ forwardedProps })); + await assertIngressError( + () => parseBrokerRuntimeAgentIngress(signed.request, options(signed.publicKeyPem)), + 403, + "BROKER_INGRESS_SCOPE_DENIED", + ); + }); + } + it("preserves application strings that do not contain a credential", async () => { const messages = [{ id: "message-1", role: "user" as const, content: "Explain Bearer authentication", }]; - const forwardedProps = { attachments: [{ url: "https://files.test/document?download=1" }] }; + const forwardedProps = { + attachments: [{ url: "https://files.test/document?name=a%20b&download=1" }, { + url: "https://files.test/invalid%escape", + }], + }; const signed = await signedRequest(invocation({ messages, forwardedProps })); const result = await parseBrokerRuntimeAgentIngress( signed.request, diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index 55a0dd4f2b..6f2bcf6b34 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -149,6 +149,7 @@ async function handler( failStart?: boolean; admitBeforeFailure?: boolean; waitForPrepare?: boolean; + abortPrepare?: boolean; waitForAuthorization?: boolean; throwingObserver?: boolean; streamFailure?: boolean; @@ -210,6 +211,11 @@ async function handler( prepareSignal = signal; prepareEntered.resolve(); if (options.waitForPrepare) await prepareRelease.promise; + if (options.abortPrepare) { + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } return { start: { session: {} } as ManagedExecutorStartInput, messages: [], @@ -494,6 +500,30 @@ describe("managed broker handler", () => { assertEquals(f.cleanupCalls, 1); }); + for (const mode of ["detached", "sse"] as const) { + for (const source of ["shutdown", "request"] as const) { + it(`maps ${source} during abortable ${mode} preparation without a setup failure`, async () => { + const controller = new AbortController(); + const f = await handler(mode, { signal: controller.signal, abortPrepare: true }); + const response = f.managed.handle(f.first.request); + await f.prepareEntered; + if (source === "shutdown") void f.managed.close(); + else controller.abort(); + try { + const result = await response; + assertEquals(result.status, source === "shutdown" ? 503 : 499); + assertEquals(await result.json(), { + errorCode: source === "shutdown" ? "BROKER_UNAVAILABLE" : "BROKER_INGRESS_ABORTED", + }); + assertEquals(f.brokerStarts, 0); + assertEquals(f.managed.active, 0); + } finally { + await f.managed.close(); + } + }); + } + } + it("does not admit a late authorization result after handler closure", async () => { const f = await handler("detached", { waitForAuthorization: true }); const response = f.managed.handle(f.first.request); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index 49cbc772a5..e455995ea4 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -183,7 +183,7 @@ export function createManagedBrokerHandler(options: { if (error instanceof BrokerIngressError) { return Response.json({ errorCode: error.errorCode }, { status: error.status }); } - if (error instanceof BrokerHandlerUnavailableError) { + if (error instanceof BrokerHandlerUnavailableError || lifetime.signal.aborted) { return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); } const aborted = request.signal.aborted || options.signal?.aborted; diff --git a/src/agent/service/managed-hosted-ingress.test.ts b/src/agent/service/managed-hosted-ingress.test.ts index 7fbdce3f6f..a685b62164 100644 --- a/src/agent/service/managed-hosted-ingress.test.ts +++ b/src/agent/service/managed-hosted-ingress.test.ts @@ -28,15 +28,28 @@ describe("managed agent ingress", () => { "inference-secret", ] ) { - for (const placement of ["message", "url", "property name"] as const) { + for ( + const placement of [ + "message", + "url", + "property name", + "encoded url", + "encoded property name", + ] as const + ) { it(`rejects ${credential} embedded in ${kind} ${placement}`, async () => { const text = placement === "message" ? `Use ${credential} for this request` : "Hello"; - const extra = placement === "url" + const visibleCredential = placement.startsWith("encoded") + ? credential.replaceAll("-", "%2D") + : credential; + const extra = placement === "url" || placement === "encoded url" ? { - attachments: [{ url: `https://files.test/document?token=${credential}&download=1` }], + attachments: [{ + url: `https://files.test/document?token=${visibleCredential}&download=1`, + }], } - : placement === "property name" - ? { [`result-${credential}-metadata`]: "value" } + : placement === "property name" || placement === "encoded property name" + ? { [`result-${visibleCredential}-metadata`]: "value" } : {}; const payload = kind === "durable" ? { From 96f86dc42ab0fc1dc96938046f5df6a83a6e06fa Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 08:15:47 +0200 Subject: [PATCH 188/194] fix(agent): bound credential decoding and classify service shutdown --- docs/guides/agent-service-runtime.md | 10 +++ src/agent/service/broker-credentials.test.ts | 34 ++++++++++ src/agent/service/broker-credentials.ts | 25 ++++++-- .../service/managed-broker-handler.test.ts | 63 +++++++++++++++++-- src/agent/service/managed-broker-handler.ts | 9 ++- .../service/managed-hosted-ingress.test.ts | 2 +- 6 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 src/agent/service/broker-credentials.test.ts diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index c5da04a2df..4d013c32b3 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -306,6 +306,16 @@ broker validates that selection against the installed grant before constructing instructions, and includes skills only when `load_skill` remains available. Refreshes without a tool selection omit the skill catalog. +Managed broker ingress checks application strings and property names for known +credentials after URI decoding. Malformed escapes do not suppress checks on valid +encoded segments. Decoding is limited to 16 passes; ingress rejects strings that +still require decoding after that limit. Keep credentials out of executor data, +including encoded URLs and metadata. + +Closing the managed broker handler or aborting its service signal returns 503 +`BROKER_UNAVAILABLE` for pending admission. Request-only cancellation returns 499 +`BROKER_INGRESS_ABORTED`. + ## Keep inference authority separate Signed runtime invocations may include an optional diff --git a/src/agent/service/broker-credentials.test.ts b/src/agent/service/broker-credentials.test.ts new file mode 100644 index 0000000000..1303db3dfb --- /dev/null +++ b/src/agent/service/broker-credentials.test.ts @@ -0,0 +1,34 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { containsBrokerCredential } from "./broker-credentials.ts"; + +describe("broker credential text normalization", () => { + for ( + const [name, value] of [ + ["malformed escape prefix", "%ZZ api%2Dauth%2Dtoken"], + ["invalid UTF-8 prefix", "%FF%61%70%69%2Dauth%2Dtoken"], + ["two URI encoding layers", "api%252Dauth%252Dtoken"], + ["encoded percent and hex characters", "%25%36%31pi%252Dauth%252Dtoken"], + ["encoded Unicode after invalid UTF-8", "%FF%C3%A9-token"], + ] as const + ) { + it(`finds a credential after ${name}`, () => { + assertEquals(containsBrokerCredential(value, ["api-auth-token", "é-token"]), true); + assertEquals( + containsBrokerCredential({ [value]: "value" }, ["api-auth-token", "é-token"]), + true, + ); + }); + } + + it("fails closed when URI nesting exceeds the bounded normalization limit", () => { + const value = "api%" + "25".repeat(32) + "2Dauth-token"; + assertEquals(containsBrokerCredential(value, ["api-auth-token"]), true); + }); + + it("preserves unrelated text and malformed escapes without credentials", () => { + for (const value of ["percent %ZZ text", "%FFhello%20world", "hello%2520world"]) { + assertEquals(containsBrokerCredential(value, ["api-auth-token"]), false); + } + }); +}); diff --git a/src/agent/service/broker-credentials.ts b/src/agent/service/broker-credentials.ts index 68bd267230..e9bb4e5d91 100644 --- a/src/agent/service/broker-credentials.ts +++ b/src/agent/service/broker-credentials.ts @@ -1,4 +1,7 @@ -/** Detect known broker credentials in literal or URI-decoded strings and property names. */ +const MAX_URI_DECODE_PASSES = 16; +const uriTextDecoder = new TextDecoder(); + +/** Reject known credentials or text that exceeds the URI normalization limit. */ export function containsBrokerCredential( value: unknown, credentials: readonly (string | null | undefined)[], @@ -23,10 +26,20 @@ function containsString(value: unknown, expected: string): boolean { } function containsCredentialText(value: string, expected: string): boolean { - if (value.includes(expected)) return true; - try { - return decodeURIComponent(value).includes(expected); - } catch { - return false; + let text = value; + for (let pass = 0; pass < MAX_URI_DECODE_PASSES; pass++) { + if (text.includes(expected)) return true; + if (!/%[0-9a-f]{2}/i.test(text)) return false; + // Decode valid byte runs independently. A malformed escape or invalid + // UTF-8 prefix must not hide a valid credential later in the same string. + text = text.replace(/(?:%[0-9a-f]{2})+/gi, (encoded) => { + const bytes = new Uint8Array(encoded.length / 3); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number.parseInt(encoded.slice(index * 3 + 1, index * 3 + 3), 16); + } + return uriTextDecoder.decode(bytes); + }); } + // Keep normalization work bounded and reject text that needs more decoding. + return text.includes(expected) || /%[0-9a-f]{2}/i.test(text); } diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index 6f2bcf6b34..b73378ecec 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -145,6 +145,7 @@ async function handler( mode: "detached" | "sse", options: { signal?: AbortSignal; + serviceSignal?: AbortSignal; requestPath?: string; failStart?: boolean; admitBeforeFailure?: boolean; @@ -183,6 +184,7 @@ async function handler( const executionController = new AbortController(); const managed = createManagedBrokerHandler({ responseMode: mode, + signal: options.serviceSignal, broker: { start: (start, lifecycle) => { brokerStarts++; @@ -203,6 +205,7 @@ async function handler( authorizeScope: async () => { authorizationEntered.resolve(); if (options.waitForAuthorization) await authorizationRelease.promise; + options.serviceSignal?.throwIfAborted(); return { userId }; }, }), @@ -501,19 +504,23 @@ describe("managed broker handler", () => { }); for (const mode of ["detached", "sse"] as const) { - for (const source of ["shutdown", "request"] as const) { + for (const source of ["shutdown", "service", "request"] as const) { it(`maps ${source} during abortable ${mode} preparation without a setup failure`, async () => { const controller = new AbortController(); - const f = await handler(mode, { signal: controller.signal, abortPrepare: true }); + const f = await handler(mode, { + signal: source === "request" ? controller.signal : undefined, + serviceSignal: source === "service" ? controller.signal : undefined, + abortPrepare: true, + }); const response = f.managed.handle(f.first.request); await f.prepareEntered; if (source === "shutdown") void f.managed.close(); else controller.abort(); try { const result = await response; - assertEquals(result.status, source === "shutdown" ? 503 : 499); + assertEquals(result.status, source === "request" ? 499 : 503); assertEquals(await result.json(), { - errorCode: source === "shutdown" ? "BROKER_UNAVAILABLE" : "BROKER_INGRESS_ABORTED", + errorCode: source === "request" ? "BROKER_INGRESS_ABORTED" : "BROKER_UNAVAILABLE", }); assertEquals(f.brokerStarts, 0); assertEquals(f.managed.active, 0); @@ -524,6 +531,54 @@ describe("managed broker handler", () => { } } + for (const mode of ["detached", "sse"] as const) { + it(`maps service shutdown during ${mode} authorization to unavailable`, async () => { + const controller = new AbortController(); + const f = await handler(mode, { + serviceSignal: controller.signal, + waitForAuthorization: true, + }); + const response = f.managed.handle(f.first.request); + await f.authorizationEntered; + controller.abort(); + f.releaseAuthorization(); + try { + const result = await response; + assertEquals(result.status, 503); + assertEquals(await result.json(), { errorCode: "BROKER_UNAVAILABLE" }); + assertEquals(f.prepareCalls, 0); + } finally { + await f.managed.close(); + } + }); + + it(`maps service shutdown during ${mode} body reading to unavailable`, async () => { + const controller = new AbortController(); + const f = await handler(mode, { serviceSignal: controller.signal }); + const blockedBody = new ReadableStream({ + start(stream) { + stream.enqueue(new TextEncoder().encode("{")); + }, + }); + const pending = f.managed.handle( + new Request(f.first.request.url, { + method: "POST", + headers: f.first.request.headers, + body: blockedBody, + }), + ); + controller.abort(); + try { + const result = await pending; + assertEquals(result.status, 503); + assertEquals(await result.json(), { errorCode: "BROKER_UNAVAILABLE" }); + assertEquals(f.prepareCalls, 0); + } finally { + await f.managed.close(); + } + }); + } + it("does not admit a late authorization result after handler closure", async () => { const f = await handler("detached", { waitForAuthorization: true }); const response = f.managed.handle(f.first.request); diff --git a/src/agent/service/managed-broker-handler.ts b/src/agent/service/managed-broker-handler.ts index e455995ea4..cfa9f8573d 100644 --- a/src/agent/service/managed-broker-handler.ts +++ b/src/agent/service/managed-broker-handler.ts @@ -180,12 +180,15 @@ export function createManagedBrokerHandler(options: { throw error; } } catch (error) { + if ( + error instanceof BrokerHandlerUnavailableError || lifetime.signal.aborted || + options.signal?.aborted + ) { + return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); + } if (error instanceof BrokerIngressError) { return Response.json({ errorCode: error.errorCode }, { status: error.status }); } - if (error instanceof BrokerHandlerUnavailableError || lifetime.signal.aborted) { - return Response.json({ errorCode: "BROKER_UNAVAILABLE" }, { status: 503 }); - } const aborted = request.signal.aborted || options.signal?.aborted; if (!aborted) { if ( diff --git a/src/agent/service/managed-hosted-ingress.test.ts b/src/agent/service/managed-hosted-ingress.test.ts index a685b62164..c611f57139 100644 --- a/src/agent/service/managed-hosted-ingress.test.ts +++ b/src/agent/service/managed-hosted-ingress.test.ts @@ -40,7 +40,7 @@ describe("managed agent ingress", () => { it(`rejects ${credential} embedded in ${kind} ${placement}`, async () => { const text = placement === "message" ? `Use ${credential} for this request` : "Hello"; const visibleCredential = placement.startsWith("encoded") - ? credential.replaceAll("-", "%2D") + ? "%ZZ" + credential.replaceAll("-", "%252D") : credential; const extra = placement === "url" || placement === "encoded url" ? { From fb22d672ed47549bd0f9bac763eb049a3d83d0cd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 08:30:59 +0200 Subject: [PATCH 189/194] test(agent): set duplex mode for streamed broker requests --- src/agent/service/managed-broker-handler.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent/service/managed-broker-handler.test.ts b/src/agent/service/managed-broker-handler.test.ts index b73378ecec..b37b57bd81 100644 --- a/src/agent/service/managed-broker-handler.test.ts +++ b/src/agent/service/managed-broker-handler.test.ts @@ -565,7 +565,8 @@ describe("managed broker handler", () => { method: "POST", headers: f.first.request.headers, body: blockedBody, - }), + duplex: "half", + } as RequestInit), ); controller.abort(); try { From b8dd5c4f0293ab7903d2e861e2cdbcdec31abf1b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 09:58:35 +0200 Subject: [PATCH 190/194] fix(agent): contain abort failures across packaged broker runs --- deno.json | 1 + docs/guides/agent-service-runtime.md | 15 + scripts/test/npm-install-smoke.ts | 45 +- .../hosted/executor-model-bridge.test.ts | 26 + src/agent/hosted/executor-model-bridge.ts | 9 +- tests/e2e/agent/managed-broker/executor.mjs | 28 + tests/e2e/agent/managed-broker/journey.mjs | 577 ++++++++++++++++++ .../agent/managed-broker/project-hooks.mjs | 55 ++ 8 files changed, 753 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/agent/managed-broker/executor.mjs create mode 100644 tests/e2e/agent/managed-broker/journey.mjs create mode 100644 tests/e2e/agent/managed-broker/project-hooks.mjs diff --git a/deno.json b/deno.json index 1a5cbf17af..bdcc160826 100644 --- a/deno.json +++ b/deno.json @@ -609,6 +609,7 @@ "test:e2e:playwright": "deno run -A npm:playwright@1.60.0 test --config=tests/e2e/playwright.config.cjs", "test:e2e:playwright:install": "deno run -A npm:playwright@1.60.0 install --with-deps chromium", "test:e2e:rsc-browser": "deno task generate && deno run --config=scripts/test.deno.json --allow-read --allow-run=deno --allow-env scripts/test/run-deno-suite.ts --suite=e2e:rsc-browser", + "test:e2e:managed-broker": "deno run -A scripts/test/npm-install-smoke.ts --managed-broker-only", "test:e2e:binary": "deno task generate && deno run --config=scripts/test.deno.json --allow-read --allow-run=deno --allow-env scripts/test/run-deno-suite.ts --suite=e2e:binary", "test:e2e:binary:fresh": "deno task generate && VERYFRONT_BINARY_FRESH=1 deno run --config=scripts/test.deno.json --allow-read --allow-run=deno --allow-env scripts/test/run-deno-suite.ts --suite=e2e:binary", "test:e2e:templates": "deno run --allow-all scripts/test/template-runtime-e2e.ts", diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 4d013c32b3..ffd026bc63 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -487,6 +487,21 @@ after decoding. Run IDs contain 1 to 128 ASCII letters, digits, underscores, or hyphens. Valid encoded run IDs are decoded before the handler receives them. Signed stream requests must sign the original encoded request path. +To verify the packaged broker and executor locally, use Node.js 22.3.0 or newer: + +```bash +deno task build:npm +deno task test:e2e:managed-broker +``` + +This suite installs the built packages and exercises signed HTTP ingress, a +separate executor over TLS, model and tool calls, SSE and detached responses, +executor termination, client cancellation, and delayed terminal persistence. +Project-controlled hooks use synthetic credential canaries with positive +controls. The npm smoke jobs run the suite on the minimum supported Node version +and the current CI version. These checks use local synthetic services. Verify +the deployed artifact and isolation configuration separately before traffic cutover. + ## Verify it worked Start the service entrypoint and call the run route directly. The default diff --git a/scripts/test/npm-install-smoke.ts b/scripts/test/npm-install-smoke.ts index 65f22810ed..93b2358f52 100644 --- a/scripts/test/npm-install-smoke.ts +++ b/scripts/test/npm-install-smoke.ts @@ -21,6 +21,9 @@ * 9. a packed agent workflow reaches a non-responsive provider, respects * its configured deadline, persists failure, and leaves the server * healthy + * 10. the managed broker executes through a separate executor process, + * retains persistence during shutdown, and keeps synthetic credentials + * out of project-controlled hooks * * The runtime under test stays the packed npm artifact under the ambient Node * version: this orchestrator only spawns `npm`, `node`, and `deno eval` @@ -90,6 +93,7 @@ async function run( options: { cwd?: string; env?: Record; + clearEnv?: boolean; timeoutMs: number; }, ): Promise { @@ -100,6 +104,7 @@ async function run( args, cwd: options.cwd, env: options.env, + clearEnv: options.clearEnv, stdin: "null", stdout: "piped", stderr: "piped", @@ -124,7 +129,12 @@ async function runChecked( step: string, command: string, args: string[], - options: { cwd?: string; env?: Record; timeoutMs: number }, + options: { + cwd?: string; + env?: Record; + clearEnv?: boolean; + timeoutMs: number; + }, ): Promise { const result = await run(command, args, options); if (result.code !== 0) { @@ -1177,6 +1187,33 @@ async function checkWorkflowTimeout( } } +async function checkManagedBroker(workDir: string): Promise { + console.log("== packed managed broker: process boundary and settlement"); + const fixtureDir = `${workDir}/managed-broker`; + await Deno.mkdir(fixtureDir, { recursive: true }); + for (const name of ["journey.mjs", "executor.mjs", "project-hooks.mjs"]) { + await Deno.copyFile( + `${ROOT_DIR}/tests/e2e/agent/managed-broker/${name}`, + `${fixtureDir}/${name}`, + ); + } + const result = await runChecked("managed broker journey", "node", [ + "--test", + "--test-concurrency=1", + `${fixtureDir}/journey.mjs`, + ], { + cwd: workDir, + clearEnv: true, + env: { + PATH: Deno.env.get("PATH") ?? "", + NODE_ENV: "production", + VF_DISABLE_LRU_INTERVAL: "1", + }, + timeoutMs: 480_000, + }); + console.log(result.stdout); +} + async function runSmoke(workDir: string): Promise { let devServer: DevServer | undefined; const shutdown = async () => { @@ -1209,6 +1246,11 @@ async function runSmoke(workDir: string): Promise { }); await npmInstall(workDir, plan, plan.rootInstallSpecs); + if (Deno.args.includes("--managed-broker-only")) { + await checkManagedBroker(workDir); + return; + } + await checkRootInstall(workDir); await checkOptionalPeer(workDir); await checkMissingExtension(workDir); @@ -1225,6 +1267,7 @@ async function runSmoke(workDir: string): Promise { await checkWorkflowTimeout(devServer, devUrl, csrfToken); await shutdown(); + await checkManagedBroker(workDir); console.log("npm install smoke: all checks passed"); } finally { for (const [signal, handler] of signalHandlers) { diff --git a/src/agent/hosted/executor-model-bridge.test.ts b/src/agent/hosted/executor-model-bridge.test.ts index a14d592453..c639557901 100644 --- a/src/agent/hosted/executor-model-bridge.test.ts +++ b/src/agent/hosted/executor-model-bridge.test.ts @@ -653,6 +653,32 @@ describe("executor managed model bridge", () => { } }); + it("contains provider stream errors during abort without an uncaught event rejection", async () => { + const channels = await connected(stubModel({ + doStream: ({ abortSignal }) => + Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + abortSignal!.addEventListener("abort", () => { + controller.error(new Error("Synthetic provider abort failure")); + }, { once: true }); + }, + }), + }), + })); + try { + const controller = new AbortController(); + const { stream } = await channels.proxy.doStream({ prompt, abortSignal: controller.signal }); + controller.abort(); + await assertRejects(() => stream.getReader().read(), Error, "cancelled"); + // Node reports rejected promises returned by EventTarget listeners as + // uncaught exceptions, even when the original promise has a catch handler. + await tick(); + } finally { + await channels.close(); + } + }); + it("retains admission until asynchronous provider stream cleanup settles", async () => { const cleanup = Promise.withResolvers(); const cancelStarted = Promise.withResolvers(); diff --git a/src/agent/hosted/executor-model-bridge.ts b/src/agent/hosted/executor-model-bridge.ts index b8b1c37777..53d29a0051 100644 --- a/src/agent/hosted/executor-model-bridge.ts +++ b/src/agent/hosted/executor-model-bridge.ts @@ -227,7 +227,12 @@ export function createExecutorModelBroker(options: { } return cancellation; }; - context.signal.addEventListener("abort", cancel, { once: true }); + // Node EventTarget reports a rejected promise returned by a listener + // as an uncaught exception. Cleanup is observed and joined separately. + const onAbort = () => { + void cancel(); + }; + context.signal.addEventListener("abort", onAbort, { once: true }); let complete = false; try { if (context.signal.aborted) { @@ -255,7 +260,7 @@ export function createExecutorModelBroker(options: { } catch (error) { yield modelFailureOrThrow(error, context); } finally { - context.signal.removeEventListener("abort", cancel); + context.signal.removeEventListener("abort", onAbort); if (!complete) await cancel().catch(() => {}); reader.releaseLock(); } diff --git a/tests/e2e/agent/managed-broker/executor.mjs b/tests/e2e/agent/managed-broker/executor.mjs new file mode 100644 index 0000000000..b5a3f08408 --- /dev/null +++ b/tests/e2e/agent/managed-broker/executor.mjs @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { + initializeExecutorRuntimeContracts, + startExecutorRuntimeEntrypoint, +} from "veryfront/agent/executor-runtime"; + +await initializeExecutorRuntimeContracts(); +const executor = await startExecutorRuntimeEntrypoint({ + readKey: () => Promise.resolve(new Uint8Array(readFileSync(0))), + readArtifact: () => + Promise.resolve({ + manifest: { + version: 1, + root: "project", + owner: { scopeKind: "global", serviceName: "synthetic-broker" }, + source: { type: "release", releaseId: "synthetic-release" }, + }, + projectDir: process.argv[2], + }), +}); +process.stdout.write(`${JSON.stringify({ pid: process.pid, port: executor.address.port })}\n`); +try { + const channel = await executor.ready; + await channel.closed; +} finally { + await executor.close(); +} diff --git a/tests/e2e/agent/managed-broker/journey.mjs b/tests/e2e/agent/managed-broker/journey.mjs new file mode 100644 index 0000000000..4b1732a78a --- /dev/null +++ b/tests/e2e/agent/managed-broker/journey.mjs @@ -0,0 +1,577 @@ +import { Buffer } from "node:buffer"; +import { spawn } from "node:child_process"; +import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; +import { assert, assertEquals } from "veryfront/testing/assert"; +import { it } from "veryfront/testing/bdd"; +import { initializeExecutorRuntimeContracts } from "veryfront/agent/executor-runtime"; +import { + connectExecutorTransport, + createManagedBrokerHandler, + createManagedBrokerPersistence, + createManagedExecutorBroker, + startNodeManagedAgentBroker, +} from "veryfront/agent/managed-broker"; + +await initializeExecutorRuntimeContracts(); +const modelId = "veryfront-cloud/openai/synthetic"; +const owner = { scopeKind: "global", serviceName: "synthetic-broker" }; +const source = { type: "release", releaseId: "synthetic-release" }; +const projectId = "00000000-0000-4000-8000-000000000005"; +const conversationId = "00000000-0000-4000-8000-000000000001"; +const messageId = "00000000-0000-4000-8000-000000000002"; +const image = `registry.example.test/executor@sha256:${"a".repeat(64)}`; +const usage = { inputTokens: 4, outputTokens: 2, totalTokens: 6 }; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function bounded(promise, label, ms = 25_000) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function scenario(kind) { + const mode = kind === "sse" || kind === "disconnect" ? "sse" : "detached"; + const project = new URL(`./project-${kind}/`, import.meta.url); + await mkdir(new URL("agents/", project), { recursive: true }); + await copyFile(new URL("./project-hooks.mjs", import.meta.url), new URL("hooks.mjs", project)); + await writeFile( + new URL("veryfront.config.ts", project), + 'import "./hooks.mjs"; export default { ai: { agents: { discovery: { paths: ["agents"] } } } };', + ); + await writeFile( + new URL("agents/probe.ts", project), + `import { agent } from "veryfront/agent"; +export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, + system: "Use host_probe once, then report its result.", tools: { host_probe: true } });`, + ); + + const secrets = Object.fromEntries(["authorization", "api", "inference", "events"].map( + (name) => [name, `synthetic-broker-private-${name}-${randomUUID()}`], + )); + const canaries = Object.values(secrets); + const runId = `run-${kind}`; + const path = `/api/control-plane/runs/${runId}/stream`; + const run = { + runId, + conversationId, + messageId, + latestEventId: 0, + latestExternalEventSequence: 0, + waitingToolCallId: null, + waitingToolName: null, + status: "running", + streamProtocolVersion: 2, + }; + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const body = JSON.stringify({ + run: { + agentServiceId: "synthetic-service", + agentId: "probe", + conversationId, + runId, + messageId, + inputAnchorMessageId: "00000000-0000-4000-8000-000000000003", + requestedByUserId: "00000000-0000-4000-8000-000000000006", + project: { projectId, projectSlug: "demo-project", runtimeTargetKind: "main_branch" }, + }, + messages: [{ id: "user", role: "user", content: "Run the host probe." }], + tools: [], + context: [], + agentSource: source, + credentials: { authToken: secrets.api, inferenceAuthToken: secrets.inference }, + }); + const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const nowSeconds = Math.floor(Date.now() / 1000); + const signed = `${encode({ alg: "EdDSA", typ: "JWT" })}.${ + encode({ + iss: "veryfront-api", + aud: "demo-project", + sub: runId, + surface: "studio", + project_id: projectId, + request_hash: createHash("sha256").update(body).digest("base64url"), + request_method: "POST", + request_path: path, + iat: nowSeconds, + exp: nowSeconds + 90, + }) + }`; + const headers = { + authorization: `Bearer ${secrets.authorization}`, + "x-veryfront-control-plane-jws": `${signed}.${ + sign(null, Buffer.from(signed), privateKey).toString("base64url") + }`, + "x-veryfront-run-event-token": secrets.events, + "content-type": "application/json", + }; + const clientAbort = new AbortController(); + const executionAbort = new AbortController(); + const secondModelCall = Promise.withResolvers(); + const terminalEntered = Promise.withResolvers(); + const terminalRelease = Promise.withResolvers(); + const finished = Promise.withResolvers(); + const allocations = []; + const releases = []; + const persisted = []; + const completions = []; + const modelCalls = []; + const tools = []; + const apiErrors = []; + let cursor = 0; + let child; + let childExited; + let childOutput = ""; + let handler; + let server; + let shutdown; + let transportClosed = false; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + + // A real HTTP endpoint checks the persistence wire contract. No live API or + // provider is contacted; credentials are freshly generated synthetic canaries. + const api = createServer(async (request, response) => { + try { + assertEquals(request.headers.authorization, `Bearer ${secrets.events}`); + let raw = ""; + for await (const chunk of request) raw += chunk; + const data = JSON.parse(raw); + for (const canary of canaries) { + assert(!raw.includes(canary), "Credential entered persistence data"); + } + response.setHeader("content-type", "application/json"); + if (Array.isArray(data.events)) { + assertEquals(request.url, `/conversations/${conversationId}/runs/${runId}/events`); + persisted.push(...data.events); + cursor += data.events.length; + response.end(JSON.stringify({ + latest_event_id: cursor, + latest_external_event_sequence: cursor, + appended_count: data.events.length, + run: { + run_id: runId, + conversation_id: conversationId, + latest_event_id: cursor, + latest_external_event_sequence: cursor, + }, + })); + } else { + assertEquals(request.url, `/runs/${runId}/complete`); + completions.push(data); + terminalEntered.resolve(); + if (kind === "delayed-persistence") await terminalRelease.promise; + response.end(JSON.stringify({ completed: true, run: { runId, status: data.status } })); + } + } catch (error) { + apiErrors.push(error); + response.statusCode = 500; + response.end("{}"); + } + }); + await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve)); + const apiUrl = `http://127.0.0.1:${api.address().port}`; + + const model = { + specificationVersion: "v3", + provider: "openai", + modelId: "synthetic", + doGenerate() { + throw new Error("Unexpected non-streaming model call"); + }, + doStream(options) { + modelCalls.push(options); + const turn = modelCalls.length; + for (const canary of canaries) { + assert( + !JSON.stringify(options).includes(canary), + "Credential entered model application data", + ); + } + assert(turn <= 2, "Unexpected extra model call"); + return Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + if (turn === 1) { + controller.enqueue({ + type: "tool-call", + toolCallId: "host-call", + toolName: "host_probe", + input: {}, + }); + controller.enqueue({ type: "finish", finishReason: "tool-calls", totalUsage: usage }); + controller.close(); + } else { + controller.enqueue({ type: "text-delta", text: "Host tool completed." }); + secondModelCall.resolve(); + if (kind === "kill" || kind === "disconnect") { + const abort = () => controller.error(new Error("Synthetic provider cancelled")); + if (options.abortSignal.aborted) abort(); + else options.abortSignal.addEventListener("abort", abort, { once: true }); + } else { + controller.enqueue({ type: "finish", finishReason: "stop", totalUsage: usage }); + controller.close(); + } + } + }, + }), + }); + }, + }; + + try { + handler = createManagedBrokerHandler({ + broker, + responseMode: mode, + resolveIngressOptions: () => ({ + publicKeyPem: publicKey.export({ type: "spki", format: "pem" }), + audience: "demo-project", + projectId, + expectedSurface: "studio", + boundSource: source, + expectedOwner: owner, + authorizeScope(authority) { + assertEquals(authority.authorization, headers.authorization); + assertEquals(authority.apiAuthToken, secrets.api); + assertEquals(authority.runEventToken, secrets.events); + return { authorized: true }; + }, + }), + prepare({ ingress }) { + assertEquals(ingress.privateAuthority.inferenceAuthToken, secrets.inference); + const persistence = createManagedBrokerPersistence({ + apiUrl, + runEventToken: ingress.privateAuthority.runEventToken, + run, + modelId, + resolveProvider: () => "openai", + fetch: globalThis.fetch, + }); + const now = Date.now(); + const allocationRequest = { + allocationId: randomUUID(), + invocationId: randomUUID(), + owner, + source, + requestedAt: now, + prepareDeadlineAt: now + 45_000, + hardDeadlineAt: now + 90_000, + }; + const binding = { + ...allocationRequest, + generation: 1, + brokerInstanceId: "synthetic-broker", + }; + delete binding.requestedAt; + delete binding.prepareDeadlineAt; + delete binding.hardDeadlineAt; + const allocation = (phase, reason) => ({ + binding, + phase, + expiresAt: allocationRequest.hardDeadlineAt, + ...(reason ? { reason } : {}), + ...(phase === "ready" + ? { + endpoint: { + address: "127.0.0.1", + port: 8081, + podUid: "synthetic-executor", + nodeName: "synthetic-node", + image, + channelAuthenticated: false, + }, + } + : {}), + }); + const allocator = { + async allocate(request, bootstrap) { + allocations.push(request); + const key = Buffer.from(bootstrap.channelKey); + child = spawn(process.execPath, [ + fileURLToPath(new URL("./executor.mjs", import.meta.url)), + fileURLToPath(project), + ], { + cwd: fileURLToPath(project), + stdio: ["pipe", "pipe", "pipe"], + // Deliberately omit all broker credentials and ambient provider configuration. + env: { + PATH: process.env.PATH, + NODE_ENV: "production", + VF_DISABLE_LRU_INTERVAL: "1", + VERYFRONT_EXECUTOR_ALLOCATION_ID: request.allocationId, + VERYFRONT_EXECUTOR_INVOCATION_ID: request.invocationId, + VERYFRONT_EXECUTOR_GENERATION: "1", + VERYFRONT_EXECUTOR_ACTIVE_DEADLINE_SECONDS: "90", + VERYFRONT_EXECUTOR_HARD_DEADLINE_AT: String(request.hardDeadlineAt), + PORT: "8081", + }, + }); + childExited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + void childExited.catch(() => {}); + const ready = Promise.withResolvers(); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + childOutput += chunk; + if (stdout.includes("\n")) { + try { + ready.resolve(JSON.parse(stdout.split("\n")[0])); + } catch { + ready.reject(new Error("Invalid executor readiness")); + } + } + }); + child.stderr.on("data", (chunk) => { + childOutput += chunk; + }); + void childExited.then( + () => ready.reject(new Error("Executor exited before readiness")), + ready.reject, + ); + child.stdin.end(key, () => key.fill(0)); + const endpoint = await bounded(ready.promise, "Executor readiness"); + assert(endpoint.pid !== process.pid); + assertEquals(endpoint.port, 8081); + return allocation("ready"); + }, + observe: () => Promise.resolve(allocation("ready")), + renew: () => Promise.resolve(allocation("ready")), + release(_binding, reason) { + releases.push(reason); + return Promise.resolve(allocation("released", reason)); + }, + }; + return Promise.resolve({ + start: { + bindSessionOwnedWork: persistence.bindSessionOwnedWork, + session: { + request: allocationRequest, + expectedBrokerInstanceId: "synthetic-broker", + expectedImage: image, + allocator, + requestTimeoutMs: 30_000, + cleanupTimeoutMs: 200, + async connectTransport(options) { + const transport = await connectExecutorTransport(options); + return { + ...transport, + close() { + transportClosed = true; + return transport.close(); + }, + }; + }, + }, + installation: { + version: 1, + root: "project", + owner, + source, + grant: { + agentId: "probe", + defaultModelId: modelId, + maxSteps: 4, + models: [{ id: modelId, maxOutputTokens: 100, providerToolNames: [] }], + allowedToolNames: ["host_probe"], + hostToolFacadeIds: ["host"], + remoteToolSourceIds: [], + execution: { + kind: "canonical", + projectId: null, + conversationId, + runId, + messageId, + providerReplay: "disabled", + }, + }, + capabilities: { + persistence: { publishParentRunEvents: "parent", toolExposureCheckpoint: "tools" }, + }, + }, + prepare: { agentId: "probe" }, + model: { + resolver: () => model, + runEventSink: persistence.modelRunEventSink, + grant: { + maxCalls: 3, + maxConcurrentCalls: 1, + models: new Map([[modelId, { maxOutputTokens: 100, providerTools: [] }]]), + }, + }, + tools: { + catalog: new Map([["host_probe", {}]]), + maxCalls: 8, + maxConcurrent: 1, + sources: new Map([["host", { + allowedToolNames: new Set(["host_probe"]), + context: {}, + source: { + id: "host", + listTools: () => + Promise.resolve([{ + name: "host_probe", + description: "Read a synthetic result", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }]), + executeTool(name) { + tools.push(name); + return Promise.resolve({ text: "host-ok" }); + }, + }, + }]]), + }, + persistence: { + publishParentRunEvents: persistence.publishParentRunEvents, + persistToolExposureCheckpoint: persistence.persistToolExposureCheckpoint, + }, + state: {}, + }, + messages: ingress.executor.input.messages.map((message) => ({ + id: message.id, + role: message.role, + timestamp: 1, + parts: [{ type: "text", text: message.content }], + })), + executionSignal: executionAbort.signal, + output: persistence.output, + async cleanup() { + await persistence.cleanup(); + finished.resolve(); + }, + }); + }, + }); + const unsupported = { handle: () => new Response(null, { status: 404 }) }; + server = await startNodeManagedAgentBroker({ + port: 0, + bindAddress: "127.0.0.1", + signals: [], + hardShutdownTimeoutMs: 10_000, + broker, + readiness: () => true, + handlers: { + signedStream: handler, + durableStart: unsupported, + agUi: unsupported, + cancel: unsupported, + resume: unsupported, + }, + }); + if (kind === "detached") { + const rejected = await fetch(`${server.url}${path}`, { + method: "POST", + body, + headers: { ...headers, "x-veryfront-control-plane-jws": "invalid" }, + }); + assertEquals(rejected.status, 401); + await rejected.body?.cancel(); + assertEquals(allocations.length, 0, "Invalid signatures must not allocate an executor"); + } + const response = await fetch(`${server.url}${path}`, { + method: "POST", + headers, + body, + signal: clientAbort.signal, + }); + assertEquals( + response.status, + mode === "sse" ? 200 : 202, + await (response.status >= 400 ? response.text() : Promise.resolve("")), + ); + let wire = ""; + if (mode === "detached") { + assertEquals(await response.json(), { accepted: true, duplicate: false }); + } + const reading = mode === "sse" + ? response.text().then((text) => { + wire = text; + }) + : Promise.resolve(); + void reading.catch(() => {}); + await bounded(secondModelCall.promise, "Second model call"); + + if (kind === "kill") { + const duplicate = await fetch(`${server.url}${path}`, { method: "POST", headers, body }); + assertEquals(await duplicate.json(), { accepted: true, duplicate: true }); + child.kill("SIGKILL"); + } else if (kind === "disconnect") { + clientAbort.abort(); + await reading.catch(() => {}); + } else if (kind === "delayed-persistence") { + await bounded(terminalEntered.promise, "Terminal persistence"); + shutdown = server.stop(); + await bounded(broker.closed, "Bounded broker closure"); + assertEquals(broker.active, 1, "Pending persistence must retain admission"); + let settled = false; + void broker.settled.then(() => { + settled = true; + }); + await tick(); + assertEquals(settled, false); + terminalRelease.resolve(); + } + await bounded(finished.promise, "Run cleanup"); + if (kind !== "disconnect") await bounded(reading, "SSE completion"); + const exit = await bounded(childExited, "Executor retirement"); + assertEquals(allocations.length, 1); + assertEquals(releases.length, 1); + assertEquals(transportClosed, true); + assertEquals(tools, ["host_probe"]); + assertEquals(modelCalls.length, 2); + assert(JSON.stringify(modelCalls[0].prompt).includes("Run the host probe.")); + assert(JSON.stringify(modelCalls[1].prompt).includes("host-ok")); + assertEquals(apiErrors, []); + if (kind === "kill") assertEquals(exit.signal, "SIGKILL"); + else assertEquals(exit.code, 0); + if (mode === "detached") { + assertEquals(completions.length, 1, "Exactly one durable terminal update"); + assertEquals(completions[0].status, kind === "kill" ? "failed" : "completed"); + if (kind !== "kill") assert(JSON.stringify(persisted).includes("Host tool completed.")); + } else { + assertEquals(completions.length, 0, "SSE persistence is owned by the stream consumer"); + if (kind === "sse") { + assertEquals(wire.split("\n").filter((line) => line === "event: RunFinished").length, 1); + assert(wire.includes("Host tool completed.")); + assert(!wire.includes("event: RunError")); + } + } + assert(persisted.length > 0, "Canonical model/tool events must reach persistence"); + const observation = JSON.parse(await readFile(new URL("observations.json", project), "utf8")); + assertEquals(observation.pid, child.pid); + assertEquals(observation.controls, { call: true, json: true, decode: true, headers: true }); + assertEquals(observation.observations, 0, "Project hooks observed a broker canary"); + for (const canary of canaries) { + assert(!`${wire}${childOutput}`.includes(canary), "Credential entered executor output"); + } + await bounded(shutdown ?? server.stop(), "Server shutdown"); + await bounded(broker.settled, "Broker settlement"); + assertEquals(broker.active, 0); + } finally { + terminalRelease.resolve(); + clientAbort.abort(); + executionAbort.abort(); + child?.kill("SIGKILL"); + await childExited?.catch(() => {}); + await bounded(shutdown ?? server?.stop() ?? broker.shutdown(), "Cleanup").catch(() => {}); + api.closeAllConnections(); + await new Promise((resolve) => api.close(resolve)); + await rm(project, { recursive: true, force: true }); + } +} + +for (const kind of ["sse", "detached", "kill", "disconnect", "delayed-persistence"]) { + it(`packed managed broker: ${kind}`, { timeout: 90_000 }, () => scenario(kind)); +} diff --git a/tests/e2e/agent/managed-broker/project-hooks.mjs b/tests/e2e/agent/managed-broker/project-hooks.mjs new file mode 100644 index 0000000000..81db8e1428 --- /dev/null +++ b/tests/e2e/agent/managed-broker/project-hooks.mjs @@ -0,0 +1,55 @@ +import { writeFileSync } from "node:fs"; +import process from "node:process"; + +// Only synthetic marker prefixes are known to the project. The actual broker +// canaries are random and never included in its environment or source files. +const prefix = "synthetic-broker-private-"; +const control = `${prefix}positive-control`; +const stringify = JSON.stringify; +const apply = Reflect.apply; +const includes = String.prototype.includes; +const report = { pid: process.pid, controls: {}, observations: 0 }; +const reportPath = new URL("./observations.json", import.meta.url); +function record(kind, value) { + if (typeof value !== "string" || !apply(includes, value, [prefix])) return; + if (apply(includes, value, [control])) report.controls[kind] = true; + else report.observations++; + writeFileSync(reportPath, stringify(report)); +} + +Function.prototype.call = new Proxy(Function.prototype.call, { + apply(target, receiver, args) { + for (let index = 0; index < args.length; index++) record("call", args[index]); + return apply(target, receiver, args); + }, +}); +JSON.stringify = new Proxy(JSON.stringify, { + apply(target, receiver, args) { + const value = apply(target, receiver, args); + record("json", value); + return value; + }, +}); +TextDecoder.prototype.decode = new Proxy(TextDecoder.prototype.decode, { + apply(target, receiver, args) { + const value = apply(target, receiver, args); + record("decode", value); + return value; + }, +}); +const iterator = Object.getPrototypeOf(new Headers().entries()); +iterator.next = new Proxy(iterator.next, { + apply(target, receiver, args) { + const next = apply(target, receiver, args); + if (!next.done) record("headers", next.value[1]); + return next; + }, +}); + +// Positive controls prevent a passing probe with inactive hooks. +(function () {}).call(null, control); +JSON.stringify({ value: control }); +new TextDecoder().decode(new TextEncoder().encode(control)); +new Headers({ "x-synthetic": control }).entries().next(); +record("environment", stringify(process.env)); +writeFileSync(reportPath, stringify(report)); From 067457211d44e7b4ea1308d3052eabdc8c8d850e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 12:00:45 +0200 Subject: [PATCH 191/194] fix(agent): align steering tools and accept empty replay state --- .../hosted/executor-checkpoint-state.test.ts | 32 +++++++++- src/agent/hosted/executor-checkpoint-state.ts | 2 +- .../hosted/executor-runtime-prepare.test.ts | 64 +++++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 2 +- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/executor-checkpoint-state.test.ts b/src/agent/hosted/executor-checkpoint-state.test.ts index 05b19efc7d..3691a7ac4b 100644 --- a/src/agent/hosted/executor-checkpoint-state.test.ts +++ b/src/agent/hosted/executor-checkpoint-state.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; import type { JsonValue } from "#veryfront/schemas/index.ts"; import type { ProviderReplayCheckpoint } from "../runtime/provider-replay.ts"; @@ -106,6 +106,36 @@ it("rejects wrong binding and capability before consuming a snapshot grant", asy await assertRejects(() => consume({ kind: "provider-replay", capabilityId: "replay" })); }); +it("accepts an empty replay snapshot without granting replay access", async () => { + for (const capabilityIds of [{}, { toolExposureCheckpoint: "tools" }]) { + const operations = createExecutorCheckpointStateOperations({ + expectedBinding: binding, + capabilityIds, + initialProviderReplayCheckpoints: [], + }); + assertEquals(operations.size, capabilityIds.toolExposureCheckpoint ? 1 : 0); + const operation = operations.get(executorInitialCheckpointsOperation); + if (capabilityIds.toolExposureCheckpoint) { + assert(operation?.mode === "stream"); + await assertRejects( + async () => { + for await ( + const _frame of operation.handle({ kind: "provider-replay", capabilityId: "tools" }, { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 10_000, + }) + ) { + throw new Error("Replay access was not granted"); + } + }, + TypeError, + "Executor checkpoint state is not authorized", + ); + } + } +}); + it("rejects duplicate anchors and missing grants instead of silently dropping state", () => { assertThrows(() => copyExecutorReplayCheckpoints([checkpoint("same", 1), checkpoint("same", 2)])); assertThrows(() => diff --git a/src/agent/hosted/executor-checkpoint-state.ts b/src/agent/hosted/executor-checkpoint-state.ts index 3cf5d6fc74..bfeac80983 100644 --- a/src/agent/hosted/executor-checkpoint-state.ts +++ b/src/agent/hosted/executor-checkpoint-state.ts @@ -70,7 +70,7 @@ export function createExecutorCheckpointStateOperations( const binding = Object.freeze(getExecutorBindingSchema().parse(options.expectedBinding)); if ( options.initialToolExposureCheckpoint && !ids.toolExposureCheckpoint || - options.initialProviderReplayCheckpoints && !ids.providerReplayCheckpoint + (options.initialProviderReplayCheckpoints?.length ?? 0) > 0 && !ids.providerReplayCheckpoint ) throw new TypeError("Executor checkpoint state is not granted"); const tool = options.initialToolExposureCheckpoint === undefined ? undefined diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 3bb35701cd..8ec67cec19 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -2071,6 +2071,70 @@ Synthetic source instructions.`, }); } + for (const extraToolCount of [0, 128]) { + it(`refreshes steering with the model-visible provider-compatible tools (${extraToolCount} extra tools)`, async () => { + const remoteNames = [ + "update_file", + ...Array.from({ length: extraToolCount }, (_, index) => `zz_tool_${index}`), + ]; + const visible: string[][] = []; + let refreshedTools: readonly string[] | undefined; + const f = fixture({ + config: { providerTools: ["web_search"] }, + grant: { + ...grant, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: ["web_search"] }]]), + allowedToolNames: remoteNames, + remoteToolSourceIds: ["api"], + execution: { kind: "ephemeral", projectId: "synthetic-project" }, + }, + facades: { + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: (_signal, availableToolNames) => { + refreshedTools = availableToolNames; + return "Updated synthetic steering"; + }, + }, + remoteToolSources: new Map([["api", { + id: "api", + listTools: () => + Promise.resolve(remoteNames.map((name) => ({ + ...syntheticRemoteTool(name), + parameters: { + type: "object", + properties: { path: { type: "string" }, project_reference: { type: "string" } }, + required: ["project_reference"], + }, + }))), + executeTool: () => Promise.resolve({ success: true }), + }]]), + resolveModelRuntime: () => ({ + ...model, + doStream: (options) => { + visible.push( + (options as ModelRuntimeCallOptions).tools?.map((tool) => tool.name) ?? [], + ); + return finishStream(visible.length === 1 ? "update_file" : undefined, { + path: "AGENTS.md", + }); + }, + }), + }, + }); + try { + await Array.fromAsync(await preparedStream(f)); + assertEquals(visible.length, 2); + assertEquals(visible[0]!.length, Math.min(extraToolCount + 2, 128)); + assert(visible[0]!.includes("web_search")); + assertEquals(visible[1], visible[0]); + assertEquals([...(refreshedTools ?? [])].sort(), [...visible[1]!].sort()); + } finally { + await f.owner.close(); + } + }); + } + it("reserves the catalog thinking budget when the request omits thinking and output limits", async () => { const selectedModel = "veryfront-cloud/anthropic/claude-sonnet-4-6"; let captured: ModelRuntimeCallOptions | undefined; diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index d3209937f7..899c1d48aa 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -752,7 +752,7 @@ export function createExecutorRuntimePreparation(input: Options) { modelId, sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, refreshSystem: facades.projectSteering - ? () => facades.projectSteering!.refresh(streamSignal, allowedToolNames) + ? () => facades.projectSteering!.refresh(streamSignal, toolAssembly.modelVisibleToolNames) : undefined, }; const runtimeOptions: NonNullable[1]> = { From a9364ea3ca0d450e1d8c742cb3bf69edc93d1ef7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 12:15:55 +0200 Subject: [PATCH 192/194] fix(agent): authorize selected provider tools in broker steering --- docs/guides/agent-service-runtime.md | 4 +- .../hosted/managed-executor-broker.test.ts | 69 ++++++++++ src/agent/hosted/managed-executor-broker.ts | 12 +- tests/e2e/agent/managed-broker/journey.mjs | 123 ++++++++++++++---- 4 files changed, 179 insertions(+), 29 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index ffd026bc63..a47a4c65fa 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -496,7 +496,9 @@ deno task test:e2e:managed-broker This suite installs the built packages and exercises signed HTTP ingress, a separate executor over TLS, model and tool calls, SSE and detached responses, -executor termination, client cancellation, and delayed terminal persistence. +executor termination, client cancellation, delayed terminal persistence, and +steering refresh with provider-native tools. Empty replay snapshots remain valid +when provider replay is disabled. Project-controlled hooks use synthetic credential canaries with positive controls. The npm smoke jobs run the suite on the minimum supported Node version and the current CI version. These checks use local synthetic services. Verify diff --git a/src/agent/hosted/managed-executor-broker.test.ts b/src/agent/hosted/managed-executor-broker.test.ts index b6ced27397..b72a566c73 100644 --- a/src/agent/hosted/managed-executor-broker.test.ts +++ b/src/agent/hosted/managed-executor-broker.test.ts @@ -653,6 +653,75 @@ describe("managed executor broker", () => { } }); + for (const selectOtherModel of [false, true]) { + it(`authorizes only the selected model's effective provider tools for steering (${selectOtherModel})`, async () => { + const otherModelId = "veryfront-cloud/anthropic/synthetic"; + const selectedModelId = selectOtherModel ? otherModelId : modelId; + const selectedTool = selectOtherModel ? "web_fetch" : "web_search"; + const rejectedTool = selectOtherModel ? "web_search" : "web_fetch"; + const f = fixture({ prepareModelId: selectedModelId }); + if (selectOtherModel) f.input.prepare.modelId = selectedModelId; + f.input.installation.grant.models = [modelId, otherModelId].map((id) => ({ + id, + maxOutputTokens: 100, + providerToolNames: ["web_search", "web_fetch"], + })); + f.input.model.grant.models = new Map([ + [modelId, { + maxOutputTokens: 100, + providerTools: [{ + type: "provider", + name: "web_search", + id: "openai.web_search", + args: {}, + }], + }], + [otherModelId, { + maxOutputTokens: 100, + providerTools: [{ + type: "provider", + name: "web_fetch", + id: "anthropic.web_fetch", + args: {}, + }], + }], + ]); + f.input.installation.capabilities.projectSteering = "steering"; + f.input.state.prepareProjectSteering = ({ definition }) => + Promise.resolve({ agent: definition }); + const selections: (readonly string[] | undefined)[] = []; + f.input.state.refreshProjectSteering = (_signal, names) => { + selections.push(names); + return Promise.resolve("Refreshed"); + }; + const broker = createManagedExecutorBroker({ maxActive: 1 }); + const runtime = await broker.start(f.input); + try { + runtime.accept({ kind: "execution" }); + assertEquals( + await f.peer!.request(executorStateOperations.refreshProjectSteering, { + capabilityId: "steering", + availableToolNames: [selectedTool], + }), + "Refreshed", + ); + for (const tool of [rejectedTool, "ungranted_host_tool"]) { + await assertRejects(() => + f.peer!.request(executorStateOperations.refreshProjectSteering, { + capabilityId: "steering", + availableToolNames: [tool], + }) + ); + } + assertEquals(selections, [[selectedTool]]); + } finally { + await runtime.close(); + await broker.shutdown(); + await broker.settled; + } + }); + } + it("uses owner-scoped tool grants for steering refresh authorization", async () => { const f = fixture(); f.input.installation.grant.allowedToolNames = ["fetch-paper"]; diff --git a/src/agent/hosted/managed-executor-broker.ts b/src/agent/hosted/managed-executor-broker.ts index b68777511a..68c6e07c87 100644 --- a/src/agent/hosted/managed-executor-broker.ts +++ b/src/agent/hosted/managed-executor-broker.ts @@ -136,6 +136,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption getExecutorRuntimePrepareRequestSchema(), input.prepare, ); + const selectedModelId = prepare.modelId ?? installation.grant.defaultModelId; if ( !sameHostedExecutorOwner(installation.owner, input.session.request.owner) || verifyHostedRuntimeSourceBinding(input.session.request.source, installation.source) !== @@ -169,6 +170,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption operationInput, installation, allowedModelIds, + selectedModelId, ); let gate: ExecutorOperationGate | undefined; @@ -182,6 +184,7 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption operationInput, installation, allowedModelIds, + selectedModelId, ); gate = createExecutorOperationGate({ binding: channelBinding, @@ -232,7 +235,6 @@ export function createManagedExecutorBroker(options: ManagedExecutorBrokerOption } throw new ExecutorAgentError(prepared.code); } - const selectedModelId = prepare.modelId ?? installation.grant.defaultModelId; if ( !allowedModelIds.has(prepared.value.modelId) || prepared.value.modelId !== selectedModelId ) { @@ -379,6 +381,7 @@ function buildBrokerOperations( input: ManagedExecutorOperationInput, installation: ExecutorRuntimeInstall, allowedModelIds: ReadonlySet, + selectedModelId: string, ): ReadonlyMap { const scope = { binding, signal, assertActive: () => signal.throwIfAborted() }; const model = installation.grant.execution.kind === "canonical" @@ -413,7 +416,12 @@ function buildBrokerOperations( projectId: execution.projectId, branchId: execution.branchId, ...input.state, - allowedToolNames: installation.grant.allowedToolNames, + allowedToolNames: [ + ...installation.grant.allowedToolNames, + ...(installation.grant.models.find((model) => model.id === selectedModelId) + ?.providerToolNames ?? + []), + ], }); const combined = new Map(); for (const operations of [model, tools, persistence, state]) { diff --git a/tests/e2e/agent/managed-broker/journey.mjs b/tests/e2e/agent/managed-broker/journey.mjs index 4b1732a78a..85a890f135 100644 --- a/tests/e2e/agent/managed-broker/journey.mjs +++ b/tests/e2e/agent/managed-broker/journey.mjs @@ -42,6 +42,8 @@ async function bounded(promise, label, ms = 25_000) { } async function scenario(kind) { + const steering = kind === "steering"; + const providerToolNames = steering ? ["web_search"] : []; const mode = kind === "sse" || kind === "disconnect" ? "sse" : "detached"; const project = new URL(`./project-${kind}/`, import.meta.url); await mkdir(new URL("agents/", project), { recursive: true }); @@ -54,7 +56,11 @@ async function scenario(kind) { new URL("agents/probe.ts", project), `import { agent } from "veryfront/agent"; export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, - system: "Use host_probe once, then report its result.", tools: { host_probe: true } });`, + system: "Use host_probe once, then report its result.", + tools: ${ + JSON.stringify(steering ? { host_probe: true, update_file: true } : { host_probe: true }) + }, + providerTools: ${JSON.stringify(providerToolNames)} });`, ); const secrets = Object.fromEntries(["authorization", "api", "inference", "events"].map( @@ -127,6 +133,7 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, const persisted = []; const completions = []; const modelCalls = []; + const steeringRefreshes = []; const tools = []; const apiErrors = []; let cursor = 0; @@ -209,6 +216,14 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, toolName: "host_probe", input: {}, }); + if (steering) { + controller.enqueue({ + type: "tool-call", + toolCallId: "steering-call", + toolName: "update_file", + input: { path: "AGENTS.md", project_reference: projectId }, + }); + } controller.enqueue({ type: "finish", finishReason: "tool-calls", totalUsage: usage }); controller.close(); } else { @@ -384,13 +399,13 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, agentId: "probe", defaultModelId: modelId, maxSteps: 4, - models: [{ id: modelId, maxOutputTokens: 100, providerToolNames: [] }], - allowedToolNames: ["host_probe"], + models: [{ id: modelId, maxOutputTokens: 100, providerToolNames }], + allowedToolNames: steering ? ["host_probe", "update_file"] : ["host_probe"], hostToolFacadeIds: ["host"], - remoteToolSourceIds: [], + remoteToolSourceIds: steering ? ["state-tools"] : [], execution: { kind: "canonical", - projectId: null, + projectId: steering ? projectId : null, conversationId, runId, messageId, @@ -399,6 +414,7 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, }, capabilities: { persistence: { publishParentRunEvents: "parent", toolExposureCheckpoint: "tools" }, + ...(steering ? { projectSteering: "steering" } : {}), }, }, prepare: { agentId: "probe" }, @@ -408,36 +424,84 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, grant: { maxCalls: 3, maxConcurrentCalls: 1, - models: new Map([[modelId, { maxOutputTokens: 100, providerTools: [] }]]), + models: new Map([[modelId, { + maxOutputTokens: 100, + providerTools: providerToolNames.map((name) => ({ + type: "provider", + name, + id: `openai.${name}`, + args: {}, + })), + }]]), }, }, tools: { - catalog: new Map([["host_probe", {}]]), + catalog: new Map([ + ["host_probe", {}], + ...(steering ? [["update_file", {}]] : []), + ]), maxCalls: 8, maxConcurrent: 1, - sources: new Map([["host", { - allowedToolNames: new Set(["host_probe"]), - context: {}, - source: { - id: "host", - listTools: () => - Promise.resolve([{ - name: "host_probe", - description: "Read a synthetic result", - parameters: { type: "object", properties: {}, additionalProperties: false }, - }]), - executeTool(name) { - tools.push(name); - return Promise.resolve({ text: "host-ok" }); + sources: new Map([ + ["host", { + allowedToolNames: new Set(["host_probe"]), + context: {}, + source: { + id: "host", + listTools: () => + Promise.resolve([{ + name: "host_probe", + description: "Read a synthetic result", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }]), + executeTool(name) { + tools.push(name); + return Promise.resolve({ text: "host-ok" }); + }, }, - }, - }]]), + }], + ...(steering + ? [["state-tools", { + allowedToolNames: new Set(["update_file"]), + context: {}, + source: { + id: "state-tools", + listTools: () => + Promise.resolve([{ + name: "update_file", + description: "Update synthetic project instructions", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + project_reference: { type: "string" }, + }, + required: ["path", "project_reference"], + }, + }]), + executeTool(name) { + tools.push(name); + return Promise.resolve({ success: true }); + }, + }, + }]] + : []), + ]), }, persistence: { publishParentRunEvents: persistence.publishParentRunEvents, persistToolExposureCheckpoint: persistence.persistToolExposureCheckpoint, + initialProviderReplayCheckpoints: [], }, - state: {}, + state: steering + ? { + prepareProjectSteering: ({ definition }) => Promise.resolve({ agent: definition }), + refreshProjectSteering(_signal, names) { + steeringRefreshes.push([...names].sort()); + return Promise.resolve("Synthetic refreshed steering"); + }, + } + : {}, }, messages: ingress.executor.input.messages.map((message) => ({ id: message.id, @@ -529,10 +593,17 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, assertEquals(allocations.length, 1); assertEquals(releases.length, 1); assertEquals(transportClosed, true); - assertEquals(tools, ["host_probe"]); + assertEquals([...tools].sort(), steering ? ["host_probe", "update_file"] : ["host_probe"]); assertEquals(modelCalls.length, 2); assert(JSON.stringify(modelCalls[0].prompt).includes("Run the host probe.")); assert(JSON.stringify(modelCalls[1].prompt).includes("host-ok")); + if (steering) { + assertEquals(steeringRefreshes, [["host_probe", "update_file", "web_search"]]); + for (const call of modelCalls) { + assert(call.tools.some((tool) => tool.name === "web_search")); + } + assert(JSON.stringify(modelCalls[1].prompt).includes("Synthetic refreshed steering")); + } assertEquals(apiErrors, []); if (kind === "kill") assertEquals(exit.signal, "SIGKILL"); else assertEquals(exit.code, 0); @@ -572,6 +643,6 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, } } -for (const kind of ["sse", "detached", "kill", "disconnect", "delayed-persistence"]) { +for (const kind of ["sse", "detached", "kill", "disconnect", "delayed-persistence", "steering"]) { it(`packed managed broker: ${kind}`, { timeout: 90_000 }, () => scenario(kind)); } From 2e544f0245f0009c6d33061c47ca4c5030bd156e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 12:34:40 +0200 Subject: [PATCH 193/194] test(agent): make packaged tool ordering explicit --- tests/e2e/agent/managed-broker/journey.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/e2e/agent/managed-broker/journey.mjs b/tests/e2e/agent/managed-broker/journey.mjs index 85a890f135..feb8c352fd 100644 --- a/tests/e2e/agent/managed-broker/journey.mjs +++ b/tests/e2e/agent/managed-broker/journey.mjs @@ -497,7 +497,9 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, ? { prepareProjectSteering: ({ definition }) => Promise.resolve({ agent: definition }), refreshProjectSteering(_signal, names) { - steeringRefreshes.push([...names].sort()); + steeringRefreshes.push( + [...names].sort((left, right) => left.localeCompare(right)), + ); return Promise.resolve("Synthetic refreshed steering"); }, } @@ -593,7 +595,10 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, assertEquals(allocations.length, 1); assertEquals(releases.length, 1); assertEquals(transportClosed, true); - assertEquals([...tools].sort(), steering ? ["host_probe", "update_file"] : ["host_probe"]); + assertEquals( + [...tools].sort((left, right) => left.localeCompare(right)), + steering ? ["host_probe", "update_file"] : ["host_probe"], + ); assertEquals(modelCalls.length, 2); assert(JSON.stringify(modelCalls[0].prompt).includes("Run the host probe.")); assert(JSON.stringify(modelCalls[1].prompt).includes("host-ok")); From 686f693d061e23908898005ec6ea129e4d6e982f Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 10 Sep 2026 12:29:40 +0200 Subject: [PATCH 194/194] fix(agent): preserve optional durable ingress context --- .../service/managed-hosted-ingress.test.ts | 33 +++++++++++++++++++ src/agent/service/managed-hosted-ingress.ts | 3 +- tests/e2e/agent/managed-broker/journey.mjs | 8 ++++- .../agent/managed-broker/project-hooks.mjs | 14 ++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/agent/service/managed-hosted-ingress.test.ts b/src/agent/service/managed-hosted-ingress.test.ts index c611f57139..2b60fb0da2 100644 --- a/src/agent/service/managed-hosted-ingress.test.ts +++ b/src/agent/service/managed-hosted-ingress.test.ts @@ -92,6 +92,39 @@ describe("managed agent ingress", () => { } } + for (const verify of [false, true]) { + it(`admits durable ingress without a resolved project slug (verify=${verify})`, async () => { + const request = new Request("https://agent.example.test/api/runs", { + method: "POST", + headers: { + "content-type": "application/json", + "X-Veryfront-Run-Event-Token": "run-event-secret", + }, + body: JSON.stringify({ + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "Hello" }] }], + context: { conversationId, branchId: "branch-1", projectId }, + durableRootRun: { runId: "run_root_1", messageId }, + }), + }); + const result = await parseManagedDurableAgentIngress(request, { + authenticate, + ...(verify + ? { verifyProjectAccess: () => Promise.resolve({ success: true as const }) } + : {}), + verifyRunEventAppendToken: () => Promise.resolve(true), + }); + if (result instanceof Response) throw new Error("Expected managed durable ingress"); + assertEquals(result.executor.projectSlug, null); + assert( + result.executor.context !== null && typeof result.executor.context === "object" && + !Array.isArray(result.executor.context), + ); + assertEquals(Object.hasOwn(result.executor.context, "projectSlug"), false); + assertEquals(result.executor.context.conversationId, conversationId); + assertEquals(result.executor.projectId, projectId); + }); + } + it("separates durable broker authority from a detached executor request", async () => { const request = new Request("https://agent.example.test/api/runs", { method: "POST", diff --git a/src/agent/service/managed-hosted-ingress.ts b/src/agent/service/managed-hosted-ingress.ts index 51d0e04d72..6cdf9d13e1 100644 --- a/src/agent/service/managed-hosted-ingress.ts +++ b/src/agent/service/managed-hosted-ingress.ts @@ -197,13 +197,14 @@ function createExecutorRequest( credentials: readonly (string | null)[], agUiInput?: ParsedHostedAgUiRequest["agUiInput"], ): ManagedAgentExecutorRequest | Response { + const { projectSlug, ...context } = parsedRequest.validatedContext; const request = { protocolVersion: 1, kind, agentId: parsedRequest.agentId ?? null, userId: parsedRequest.userId, messages: parsedRequest.messages, - context: parsedRequest.validatedContext, + context: projectSlug === undefined ? context : { ...context, projectSlug }, projectId: parsedRequest.projectId, projectSlug: parsedRequest.projectSlug ?? null, conversationId: parsedRequest.conversationId ?? null, diff --git a/tests/e2e/agent/managed-broker/journey.mjs b/tests/e2e/agent/managed-broker/journey.mjs index feb8c352fd..333b31e910 100644 --- a/tests/e2e/agent/managed-broker/journey.mjs +++ b/tests/e2e/agent/managed-broker/journey.mjs @@ -627,7 +627,13 @@ export default agent({ id: "probe", model: ${JSON.stringify(modelId)}, assert(persisted.length > 0, "Canonical model/tool events must reach persistence"); const observation = JSON.parse(await readFile(new URL("observations.json", project), "utf8")); assertEquals(observation.pid, child.pid); - assertEquals(observation.controls, { call: true, json: true, decode: true, headers: true }); + assertEquals(observation.controls, { + call: true, + json: true, + decode: true, + headers: true, + tee: true, + }); assertEquals(observation.observations, 0, "Project hooks observed a broker canary"); for (const canary of canaries) { assert(!`${wire}${childOutput}`.includes(canary), "Credential entered executor output"); diff --git a/tests/e2e/agent/managed-broker/project-hooks.mjs b/tests/e2e/agent/managed-broker/project-hooks.mjs index 81db8e1428..ad2ff7854d 100644 --- a/tests/e2e/agent/managed-broker/project-hooks.mjs +++ b/tests/e2e/agent/managed-broker/project-hooks.mjs @@ -46,6 +46,15 @@ iterator.next = new Proxy(iterator.next, { }, }); +const originalTee = ReadableStream.prototype.tee; +const pendingReads = []; +ReadableStream.prototype.tee = function () { + const branches = apply(originalTee, this, []); + const [forward, observer] = apply(originalTee, branches[0], []); + pendingReads.push(new Response(observer).text().then((value) => record("tee", value))); + return [forward, branches[1]]; +}; + // Positive controls prevent a passing probe with inactive hooks. (function () {}).call(null, control); JSON.stringify({ value: control }); @@ -53,3 +62,8 @@ new TextDecoder().decode(new TextEncoder().encode(control)); new Headers({ "x-synthetic": control }).entries().next(); record("environment", stringify(process.env)); writeFileSync(reportPath, stringify(report)); + +const bodyControl = new Response(control).body; +const controlBranches = bodyControl.tee(); +await Promise.all(controlBranches.map((branch) => new Response(branch).text())); +await Promise.all(pendingReads);