From 3a5b50163c10bdb76d6c7529671bb866de68caca Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 23 Sep 2026 20:59:19 +0200 Subject: [PATCH 1/6] feat(sdk): add immutable Flow Tool manifest contracts --- packages/sdk/FLOW-TOOLS.md | 94 +++++++++++ packages/sdk/src/flow-tool-definitions.ts | 27 +++ packages/sdk/src/flow-tool-manifest.ts | 102 ++++++++++++ packages/sdk/src/flow-tool-schema.ts | 64 +++++++ packages/sdk/src/index.ts | 10 ++ packages/sdk/tests/flow-tool-manifest.test.ts | 157 ++++++++++++++++++ 6 files changed, 454 insertions(+) create mode 100644 packages/sdk/FLOW-TOOLS.md create mode 100644 packages/sdk/src/flow-tool-definitions.ts create mode 100644 packages/sdk/src/flow-tool-manifest.ts create mode 100644 packages/sdk/src/flow-tool-schema.ts create mode 100644 packages/sdk/tests/flow-tool-manifest.test.ts diff --git a/packages/sdk/FLOW-TOOLS.md b/packages/sdk/FLOW-TOOLS.md new file mode 100644 index 000000000..aed182379 --- /dev/null +++ b/packages/sdk/FLOW-TOOLS.md @@ -0,0 +1,94 @@ +# Flow Tool manifests (local contract slice) + +`FlowToolManifestV1` is an explicit, opt-in SDK contract. It does **not** infer +runtime schemas from TypeScript generics or change existing flow headers/runs. + +```ts +import { + createFlowToolManifest, validateFlowToolInput, validateFlowToolResult, + flowToolFunctionDefinition, flowToolMcpDefinition, +} from '@relayflows/sdk'; + +const manifest = createFlowToolManifest({ + name: 'review_pull_request', + description: 'Review a pinned PR. A hold verdict is not approval to merge.', + flow: { + name: 'pr-review', version: '1.0.0', + // Replace this example value with the separately verified bundle digest. + digest: `sha256:${'a'.repeat(64)}`, + }, + inputSchema: { + type: 'object', properties: { pr: { type: 'integer', minimum: 1 } }, + required: ['pr'], additionalProperties: false, + }, + resultSchema: { + type: 'object', properties: { verdict: { enum: ['hold', 'pass'] } }, + required: ['verdict'], additionalProperties: false, + }, +}); + +const input = validateFlowToolInput(manifest, { pr: 42 }); +const result = validateFlowToolResult(manifest, { verdict: 'hold' }); +const nativeDefinition = flowToolFunctionDefinition(manifest); +const mcpDefinition = flowToolMcpDefinition(manifest); +``` + +These calls validate/describe data; **none executes a flow**. Input/result +validation returns a frozen snapshot without coercing types, filling defaults, +removing extra fields, or mutating the caller's data. A structurally valid +`hold` result is not a successful business decision. + +## Identity and schema contract + +The flow name, semantic version and `sha256:<64 lowercase hex>` bundle digest +are immutable declarations. The separate manifest `digest` hashes the UTF-8 +canonical JSON of every manifest field except `digest`: recursively sorted +object keys, array order preserved, JSON number/string encoding, no whitespace +or trailing newline. This uses the SDK's existing canonical serializer; it +is not a new cross-language JCS claim. `canonicalFlowToolManifest` returns the +canonical complete envelope. `parseFlowToolManifest(json, trustedDigest)` checks +the version, full schema contract and digest, optionally pinning it to a +caller-trusted catalog value. Reordering keys preserves identity; changing a +schema, description, version or flow digest changes it. + +A digest is **not a signature, provenance proof or authorization**. This module +does not verify that the referenced bundle exists, contains this manifest or +was admitted/executed by Cloud. An attacker can rehash changed metadata; callers +must establish their trusted catalog/digest separately. + +V1 is a closed JSON Schema 2020-12 profile. Both roots must explicitly declare +`type: 'object'`. Supported keywords are `$schema`, `$defs`, document-local +JSON-Pointer `$ref`, `title`, `description`, `type`, `enum`, `const`, `properties`, +`required`, `additionalProperties`, `items`, `minItems`, `maxItems`, `uniqueItems`, +`minLength`, `maxLength`, `minimum`, `maximum`, `exclusiveMinimum`, +`exclusiveMaximum`, `multipleOf`, `minProperties`, `maxProperties`, `anyOf`, +`oneOf`, `allOf`, and `not`. References must resolve locally and must not form +non-consuming cycles. Invalid schemas are refused at construction/parse time. + +Unsupported keywords are rejected, never dropped: notably remote references, +`format`, `pattern`, `patternProperties`, `default`, custom keywords and other +drafts. This intentionally narrower first profile avoids silent validation or +provider-translation differences. JSON snapshots are limited to depth 64, +4,096 nodes/properties and 256 KiB encoded bytes, for the complete manifest +and independently for each input/result. Non-finite numbers, cycles, proxies, +accessors, functions, sparse arrays and non-plain objects are refused using +the SDK's existing behavior-free JSON snapshot boundary. + +## Adapter boundaries + +The native descriptor has `{type:'function', name, description, parameters}`. +The MCP descriptor has `{name, description, inputSchema, outputSchema}` and +passes the installed MCP SDK's Tool schema. Both preserve schema content. The +native shape is a neutral function-call descriptor: individual providers may +require a wrapper or a stricter schema subset; no provider-specific strict mode +is enabled, and no constraint is silently rewritten. Neither definition carries +credentials or asserts read-only, destructive, idempotent or business-success +annotations. Keep its manifest identity in the adapter's trusted binding. + +There is deliberately **no MCP server or invocation handler** in this slice. +The authored runtime currently returns a completion reason and journal-step +references, not this flow-defined result object or a verified bundle-admission +receipt. A callable adapter must first bind verified execution to the manifest, +validate input before effects and validate the real result after execution. +This module does not implement hosted auth/admission, permission enforcement, +async events, cancellation, resume, idempotency, or business-result guarantees. diff --git a/packages/sdk/src/flow-tool-definitions.ts b/packages/sdk/src/flow-tool-definitions.ts new file mode 100644 index 000000000..58b2f9661 --- /dev/null +++ b/packages/sdk/src/flow-tool-definitions.ts @@ -0,0 +1,27 @@ +import { parseFlowToolManifest, type FlowToolManifestV1 } from './flow-tool-manifest.js'; +import type { FlowToolObjectSchema } from './flow-tool-schema.js'; + +export interface FlowToolFunctionDefinition { + readonly type: 'function'; + readonly name: string; + readonly description: string; + readonly parameters: FlowToolObjectSchema; +} +export interface FlowToolMcpDefinition { + readonly name: string; + readonly description: string; + readonly inputSchema: FlowToolObjectSchema; + readonly outputSchema: FlowToolObjectSchema; +} + +/** Plain function-call descriptor. Provider-specific strict-mode translation is not implied. */ +export function flowToolFunctionDefinition(value: FlowToolManifestV1): FlowToolFunctionDefinition { + const manifest = parseFlowToolManifest(value); + return Object.freeze({ type: 'function', name: manifest.name, description: manifest.description, parameters: manifest.inputSchema }); +} + +/** MCP tools/list metadata only: no server, call handler, authority, or idempotency claim. */ +export function flowToolMcpDefinition(value: FlowToolManifestV1): FlowToolMcpDefinition { + const manifest = parseFlowToolManifest(value); + return Object.freeze({ name: manifest.name, description: manifest.description, inputSchema: manifest.inputSchema, outputSchema: manifest.resultSchema }); +} diff --git a/packages/sdk/src/flow-tool-manifest.ts b/packages/sdk/src/flow-tool-manifest.ts new file mode 100644 index 000000000..de6cc9298 --- /dev/null +++ b/packages/sdk/src/flow-tool-manifest.ts @@ -0,0 +1,102 @@ +import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; +import { snapshotJsonValue, type JsonValue } from './json-value.js'; +import { FLOW_TOOL_LIMITS, FLOW_TOOL_SCHEMA_DIALECT, flowToolSchema, compileFlowToolSchema } from './flow-tool-schema.js'; +import type { FlowToolJson, FlowToolObjectSchema } from './flow-tool-schema.js'; + +export type FlowToolDigest = `sha256:${string}`; +export interface FlowToolDeclarationV1 { + readonly name: string; + readonly description: string; + /** Author-supplied immutable bundle reference; not proof of bundle verification. */ + readonly flow: Readonly<{ name: string; version: string; digest: FlowToolDigest }>; + readonly inputSchema: FlowToolObjectSchema; + readonly resultSchema: FlowToolObjectSchema; +} +export interface FlowToolManifestV1 extends FlowToolDeclarationV1 { + readonly manifestVersion: 1; + readonly schemaDialect: typeof FLOW_TOOL_SCHEMA_DIALECT; + /** SHA-256 of canonical manifest content excluding this field. Not a signature. */ + readonly digest: FlowToolDigest; +} + +const FIELDS = ['name', 'description', 'flow', 'inputSchema', 'resultSchema']; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +function record(value: JsonValue, fields: readonly string[], at: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${at}: expected an object`); + if (Object.keys(value).some(key => !fields.includes(key)) || fields.some(key => !Object.hasOwn(value, key))) { + throw new TypeError(`${at}: missing or unknown fields`); + } + return value; +} + +function declaration(value: unknown): FlowToolDeclarationV1 { + const data = record(snapshotJsonValue(value, 'flow tool', FLOW_TOOL_LIMITS), FIELDS, 'flow tool'); + if (typeof data.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(data.name)) { + throw new TypeError('flow tool.name: expected a portable tool name (1–64 ASCII characters)'); + } + if (typeof data.description !== 'string' || !data.description.trim() || data.description.length > 4096) { + throw new TypeError('flow tool.description: expected 1–4096 characters of operator-authored description'); + } + const flow = record(data.flow!, ['name', 'version', 'digest'], 'flow tool.flow'); + if (typeof flow.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(flow.name)) { + throw new TypeError('flow tool.flow.name: expected a safe flow name'); + } + if (typeof flow.version !== 'string' || flow.version.length > 128 || !VERSION.test(flow.version) + || flow.version.split('+')[0]!.split('-').slice(1).join('-').split('.').some(id => /^0\d+$/.test(id))) { + throw new TypeError('flow tool.flow.version: expected a semantic version'); + } + if (typeof flow.digest !== 'string' || !DIGEST.test(flow.digest)) throw new TypeError('flow tool.flow.digest: expected sha256 and 64 lowercase hex digits'); + return Object.freeze({ + name: data.name, description: data.description, + flow: Object.freeze({ name: flow.name, version: flow.version, digest: flow.digest as FlowToolDigest }), + inputSchema: flowToolSchema(data.inputSchema, 'flow tool.inputSchema'), + resultSchema: flowToolSchema(data.resultSchema, 'flow tool.resultSchema'), + }); +} + +/** Explicit runtime contract; TypeScript generics are never treated as schemas. */ +export function createFlowToolManifest(value: FlowToolDeclarationV1): FlowToolManifestV1 { + const content = Object.freeze({ manifestVersion: 1 as const, schemaDialect: FLOW_TOOL_SCHEMA_DIALECT, ...declaration(value) }); + return snapshotJsonValue({ ...content, digest: `sha256:${sha256(canonicalize(content))}` }, + 'flow tool manifest', FLOW_TOOL_LIMITS) as unknown as FlowToolManifestV1; +} + +/** Verify untrusted JSON and optionally bind it to a caller-trusted catalog digest. */ +export function parseFlowToolManifest(value: unknown, expectedDigest?: FlowToolDigest): FlowToolManifestV1 { + const data = record(snapshotJsonValue(value, 'flow tool manifest', FLOW_TOOL_LIMITS), + [...FIELDS, 'manifestVersion', 'schemaDialect', 'digest'], 'flow tool manifest'); + if (data.manifestVersion !== 1 || data.schemaDialect !== FLOW_TOOL_SCHEMA_DIALECT) { + throw new TypeError('flow tool manifest: unsupported manifest version or schema dialect'); + } + const parsed = createFlowToolManifest(Object.fromEntries(FIELDS.map(key => [key, data[key]])) as unknown as FlowToolDeclarationV1); + if (data.digest !== parsed.digest || (expectedDigest !== undefined && parsed.digest !== expectedDigest)) { + throw new TypeError('flow tool manifest: digest mismatch'); + } + return parsed; +} + +export function canonicalFlowToolManifest(value: FlowToolManifestV1): string { + return canonicalize(parseFlowToolManifest(value)); +} + +function validate(value: unknown, schema: FlowToolObjectSchema, at: string): Readonly> { + const snapshot = snapshotJsonValue(value, at, FLOW_TOOL_LIMITS); + const validator = compileFlowToolSchema(schema); + if (!validator(snapshot)) { + // Report schema location, never data or the provider's raw response. + throw new TypeError(`${at}: schema validation failed (${validator.errors?.[0]?.keyword ?? 'invalid'})`); + } + return snapshot as Readonly>; +} + +export function validateFlowToolInput(manifest: FlowToolManifestV1, input: unknown): Readonly> { + return validate(input, parseFlowToolManifest(manifest).inputSchema, 'flow tool input'); +} + +/** Validates structure only, not business truth or successful execution. */ +export function validateFlowToolResult(manifest: FlowToolManifestV1, result: unknown): Readonly> { + return validate(result, parseFlowToolManifest(manifest).resultSchema, 'flow tool result'); +} diff --git a/packages/sdk/src/flow-tool-schema.ts b/packages/sdk/src/flow-tool-schema.ts new file mode 100644 index 000000000..6b0ceeba2 --- /dev/null +++ b/packages/sdk/src/flow-tool-schema.ts @@ -0,0 +1,64 @@ +import Ajv2020 from 'ajv/dist/2020.js'; +import { jsonSchemaBoundError } from './json-schema-bound.js'; +import { snapshotJsonValue, type JsonValue } from './json-value.js'; + +export const FLOW_TOOL_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema' as const; +export type FlowToolJson = null | boolean | number | string | readonly FlowToolJson[] + | { readonly [key: string]: FlowToolJson }; +export type FlowToolObjectSchema = Readonly> & { readonly type: 'object' }; +export const FLOW_TOOL_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 4096, maxBytes: 262144 }); + +// A deliberately explicit portable v1 profile, not silently ignored JSON Schema. +const KEYWORDS = new Set([ + '$schema', '$defs', '$ref', 'title', 'description', 'type', 'enum', 'const', + 'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', + 'uniqueItems', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum', + 'exclusiveMaximum', 'multipleOf', 'minProperties', 'maxProperties', 'anyOf', 'oneOf', 'allOf', 'not', +]); + +function inspect(schema: JsonValue, at: string): void { + if (typeof schema === 'boolean') return; + if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) { + throw new TypeError(`${at}: expected a JSON Schema object or boolean`); + } + for (const [key, value] of Object.entries(schema)) { + if (!KEYWORDS.has(key)) throw new TypeError(`${at}: unsupported v1 schema keyword ${key}`); + if (key === '$schema' && value !== FLOW_TOOL_SCHEMA_DIALECT) { + throw new TypeError(`${at}: expected JSON Schema 2020-12`); + } + if (key === '$ref' && (typeof value !== 'string' || !value.startsWith('#/'))) { + throw new TypeError(`${at}: only document-local JSON Pointer references are supported`); + } + if (key === 'properties' || key === '$defs') { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${at}.${key}: expected an object`); + } + for (const [name, child] of Object.entries(value)) inspect(child, `${at}.${key}.${name}`); + } else if (key === 'items' || key === 'additionalProperties' || key === 'not') { + inspect(value, `${at}.${key}`); + } else if (key === 'anyOf' || key === 'oneOf' || key === 'allOf') { + if (!Array.isArray(value)) throw new TypeError(`${at}.${key}: expected an array`); + value.forEach((child, i) => inspect(child, `${at}.${key}[${i}]`)); + } + } +} + +export function compileFlowToolSchema(schema: FlowToolObjectSchema) { + // No coercion, defaults, removal of extras, network retrieval, or ignored formats. + return new Ajv2020({ strict: true, strictRequired: true, allowUnionTypes: true, allErrors: false }) + .compile(schema); +} + +export function flowToolSchema(value: unknown, at: string): FlowToolObjectSchema { + const snapshot = snapshotJsonValue(value, at, FLOW_TOOL_LIMITS); + inspect(snapshot, at); + if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot) || snapshot.type !== 'object') { + throw new TypeError(`${at}: tool schemas must explicitly have type "object"`); + } + const bounded = jsonSchemaBoundError(snapshot); + if (bounded !== undefined) throw new TypeError(`${at}: ${bounded}`); + const schema = snapshot as FlowToolObjectSchema; + try { compileFlowToolSchema(schema); } + catch { throw new TypeError(`${at}: invalid or unsupported JSON Schema`); } + return schema; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9b1c2c3cd..3175046fa 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -118,6 +118,16 @@ export { export { parseHumanTo, parseHumanRecipient, humanRecipientProvider, type HumanRecipient, type HumanRecipientParse } from './human-to.js'; export { canonicalize, specHash } from './canonical.js'; +export { + createFlowToolManifest, parseFlowToolManifest, canonicalFlowToolManifest, + validateFlowToolInput, validateFlowToolResult, + type FlowToolDeclarationV1, type FlowToolManifestV1, type FlowToolDigest, +} from './flow-tool-manifest.js'; +export { FLOW_TOOL_SCHEMA_DIALECT, type FlowToolJson, type FlowToolObjectSchema } from './flow-tool-schema.js'; +export { + flowToolFunctionDefinition, flowToolMcpDefinition, + type FlowToolFunctionDefinition, type FlowToolMcpDefinition, +} from './flow-tool-definitions.js'; export { compileAndHash, compileSpec, diff --git a/packages/sdk/tests/flow-tool-manifest.test.ts b/packages/sdk/tests/flow-tool-manifest.test.ts new file mode 100644 index 000000000..d6019b897 --- /dev/null +++ b/packages/sdk/tests/flow-tool-manifest.test.ts @@ -0,0 +1,157 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + createFlowToolManifest, parseFlowToolManifest, canonicalFlowToolManifest, + validateFlowToolInput, validateFlowToolResult, type FlowToolDeclarationV1, +} from '../src/flow-tool-manifest.js'; +import { flowToolFunctionDefinition, flowToolMcpDefinition } from '../src/flow-tool-definitions.js'; +import { canonicalize } from '../src/canonical.js'; + +function declaration(): FlowToolDeclarationV1 { + return { + name: 'review_pull_request', description: 'Review a pinned PR; a hold verdict is not approval to merge.', + flow: { name: 'pr-review', version: '1.2.3', digest: `sha256:${'a'.repeat(64)}` }, + inputSchema: { type: 'object', properties: { pr: { type: 'integer', minimum: 1 }, mode: { enum: ['review'] } }, required: ['pr', 'mode'], additionalProperties: false }, + resultSchema: { type: 'object', properties: { verdict: { enum: ['hold', 'pass'] } }, required: ['verdict'], additionalProperties: false }, + }; +} +const unchecked = (value: unknown) => createFlowToolManifest(value as FlowToolDeclarationV1); + +describe('FlowToolManifestV1', () => { + it('creates a reproducible canonical contract and independently verifiable digest', () => { + const manifest = createFlowToolManifest(declaration()); + const { digest, ...content } = manifest; + expect(digest).toBe(`sha256:${createHash('sha256').update(canonicalize(content)).digest('hex')}`); + expect(canonicalFlowToolManifest(manifest)).toBe(canonicalize(manifest)); + expect(parseFlowToolManifest(JSON.parse(canonicalFlowToolManifest(manifest)), digest)).toEqual(manifest); + const reordered = JSON.parse(JSON.stringify(declaration())) as Record; + expect(unchecked(Object.fromEntries(Object.entries(reordered).reverse())).digest).toBe(digest); + expect(canonicalFlowToolManifest(manifest)).not.toContain('\n'); + }); + + it('snapshots and deeply freezes caller-owned metadata and schemas', () => { + const raw = JSON.parse(JSON.stringify(declaration())); + const manifest = unchecked(raw); + raw.flow.digest = `sha256:${'b'.repeat(64)}`; + raw.inputSchema.properties.pr.minimum = 100; + expect(manifest.flow.digest).toBe(`sha256:${'a'.repeat(64)}`); + expect(validateFlowToolInput(manifest, { pr: 1, mode: 'review' })).toEqual({ pr: 1, mode: 'review' }); + expect(Object.isFrozen(manifest)).toBe(true); + expect(Object.isFrozen(manifest.flow)).toBe(true); + expect(Object.isFrozen(manifest.inputSchema.properties)).toBe(true); + expect(() => { (manifest as unknown as { name: string }).name = 'changed'; }).toThrow(); + }); + + it.each(['name', 'description', 'flow', 'inputSchema', 'resultSchema'])('binds %s into the digest and refuses stale metadata', field => { + const original = createFlowToolManifest(declaration()); + const changed = JSON.parse(JSON.stringify(original)); + if (field === 'name') changed.name = 'changed'; + if (field === 'description') changed.description = 'Changed operator metadata'; + if (field === 'flow') changed.flow.version = '2.0.0'; + if (field === 'inputSchema') changed.inputSchema.properties.pr.minimum = 2; + if (field === 'resultSchema') changed.resultSchema.properties.verdict.enum.push('unknown'); + expect(() => parseFlowToolManifest(changed)).toThrow('digest mismatch'); + expect(() => flowToolFunctionDefinition(changed)).toThrow('digest mismatch'); + expect(() => flowToolMcpDefinition(changed)).toThrow('digest mismatch'); + }); + + it('rejects an unexpected trusted digest even when the manifest is internally consistent', () => { + expect(() => parseFlowToolManifest(createFlowToolManifest(declaration()), `sha256:${'0'.repeat(64)}`)).toThrow('digest mismatch'); + }); + + it.each([ + null, [], {}, { ...declaration(), name: '' }, { ...declaration(), name: 'a.b' }, + { ...declaration(), name: 'a'.repeat(65) }, { ...declaration(), description: ' ' }, + { ...declaration(), description: 'a'.repeat(4097) }, { ...declaration(), permissions: ['admin'] }, + { ...declaration(), flow: { ...declaration().flow, digest: 'main' } }, + { ...declaration(), flow: { ...declaration().flow, digest: `sha256:${'A'.repeat(64)}` } }, + { ...declaration(), flow: { ...declaration().flow, version: 'latest' } }, + { ...declaration(), flow: { ...declaration().flow, version: '1.0.0-01' } }, + { ...declaration(), flow: { ...declaration().flow, name: '../escape' } }, + ])('rejects malformed declaration %#', value => expect(() => unchecked(value)).toThrow()); + + it.each(['manifestVersion', 'schemaDialect', 'digest'])('requires valid %s', field => { + const raw = JSON.parse(JSON.stringify(createFlowToolManifest(declaration()))); + raw[field] = 'unknown'; + expect(() => parseFlowToolManifest(raw)).toThrow(); + }); + + it('never executes accessors, proxies or toJSON while constructing identity', () => { + let executed = 0; + const getter = { ...declaration(), get description() { executed++; return 'attacker'; } }; + expect(() => unchecked(getter)).toThrow('accessors'); + expect(() => unchecked(new Proxy(declaration(), { ownKeys() { executed++; return []; } }))).toThrow('Proxy'); + expect(() => unchecked({ ...declaration(), toJSON() { executed++; return {}; } })).toThrow(); + expect(executed).toBe(0); + }); +}); + +describe('explicit input and result schema boundary', () => { + it('validates without coercion, defaults, removal or mutation; a hold result remains valid', () => { + const manifest = createFlowToolManifest(declaration()); + const input = { pr: 42, mode: 'review' }; + const snapshot = validateFlowToolInput(manifest, input); + expect(snapshot).toEqual(input); + expect(snapshot).not.toBe(input); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(validateFlowToolResult(manifest, { verdict: 'hold' })).toEqual({ verdict: 'hold' }); + expect(() => validateFlowToolInput(manifest, { pr: '42', mode: 'review' })).toThrow(); + }); + + it.each([{}, null, [], { pr: 0, mode: 'review' }, { pr: 1.5, mode: 'review' }, { pr: 1, mode: 'merge' }, { pr: 1, mode: 'review', authority: 'admin' }])('refuses invalid root input %#', value => { + expect(() => validateFlowToolInput(createFlowToolManifest(declaration()), value)).toThrow('flow tool input'); + }); + + it.each([{}, null, { verdict: 'completed' }, { verdict: 'pass', secret: 'secret-fixture' }])('refuses invalid result %# without echoing data', value => { + try { validateFlowToolResult(createFlowToolManifest(declaration()), value); throw new Error('accepted'); } + catch (error) { expect(String(error)).toContain('flow tool result'); expect(String(error)).not.toContain('secret-fixture'); } + }); + + it.each([ + true, false, { type: 'array' }, { type: 'object', typo: true }, + { type: 'object', $schema: 'http://json-schema.org/draft-07/schema#' }, + { type: 'object', $ref: 'https://example.invalid/schema' }, + { type: 'object', $ref: '#/$defs/missing' }, + { type: 'object', properties: { x: { type: 'string', format: 'email' } } }, + { type: 'object', properties: { x: { type: 'string', pattern: '(a+)+$' } } }, + { type: 'object', properties: { x: { type: 'string', default: 'not-applied' } } }, + { type: 'object', required: ['undeclared'] }, + { type: 'object', $defs: { loop: { $ref: '#/$defs/loop' } }, $ref: '#/$defs/loop' }, + ])('fails closed on unsupported/invalid schema %#', schema => { + expect(() => unchecked({ ...declaration(), inputSchema: schema })).toThrow(); + expect(() => unchecked({ ...declaration(), resultSchema: schema })).toThrow(); + }); + + it('supports closed nested objects, local definitions, arrays, bounds and nullable unions', () => { + const schema = { type: 'object', $defs: { row: { type: 'object', properties: { label: { type: ['string', 'null'], minLength: 1 } }, required: ['label'], additionalProperties: false } }, properties: { rows: { type: 'array', items: { $ref: '#/$defs/row' }, minItems: 1, maxItems: 2 } }, required: ['rows'], additionalProperties: false }; + const manifest = unchecked({ ...declaration(), inputSchema: schema }); + expect(validateFlowToolInput(manifest, { rows: [{ label: null }, { label: 'x' }] })).toBeDefined(); + for (const value of [{ rows: [] }, { rows: [{ label: '' }] }, { rows: [{ label: 'x', extra: true }] }]) { + expect(() => validateFlowToolInput(manifest, value)).toThrow(); + } + }); + + it('bounds non-JSON, cyclic, oversized and excessively deep runtime values', () => { + const manifest = unchecked({ ...declaration(), inputSchema: { type: 'object' } }); + const cycle: Record = {}; cycle.self = cycle; + let deep: unknown = {}; for (let i = 0; i < 70; i++) deep = { child: deep }; + for (const input of [{ x: NaN }, { x: BigInt(1) }, { x: new Date() }, cycle, deep, { text: 'x'.repeat(262145) }, { list: new Array(5000).fill(0) }]) { + expect(() => validateFlowToolInput(manifest, input)).toThrow(); + } + }); +}); + +describe('metadata adapters', () => { + it('emits standard definitions with exact schema parity and no invented execution claims', () => { + const manifest = createFlowToolManifest(declaration()); + const native = flowToolFunctionDefinition(manifest), mcp = flowToolMcpDefinition(manifest); + expect(native).toEqual({ type: 'function', name: manifest.name, description: manifest.description, parameters: manifest.inputSchema }); + expect(mcp).toEqual({ name: manifest.name, description: manifest.description, inputSchema: manifest.inputSchema, outputSchema: manifest.resultSchema }); + expect(ToolSchema.parse(mcp)).toEqual(mcp); + expect(Object.isFrozen(native.parameters)).toBe(true); + expect(Object.isFrozen(mcp.outputSchema)).toBe(true); + expect(mcp).not.toHaveProperty('annotations'); + expect(native).not.toHaveProperty('strict'); + }); +}); From f37ead4e125a2a9062ae3eea3f5ebbdde6f9afc9 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 23 Sep 2026 21:51:34 +0200 Subject: [PATCH 2/6] test(sdk): verify public Flow Tool contract boundary --- packages/sdk/FLOW-TOOLS.md | 17 ++++++ packages/sdk/src/flow-tool-manifest.ts | 4 +- packages/sdk/src/flow-tool-schema.ts | 2 + packages/sdk/tests/flow-tool-manifest.test.ts | 1 + .../sdk/tests/flow-tool-public-api.test.ts | 60 +++++++++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/tests/flow-tool-public-api.test.ts diff --git a/packages/sdk/FLOW-TOOLS.md b/packages/sdk/FLOW-TOOLS.md index aed182379..3ea06e04f 100644 --- a/packages/sdk/FLOW-TOOLS.md +++ b/packages/sdk/FLOW-TOOLS.md @@ -92,3 +92,20 @@ receipt. A callable adapter must first bind verified execution to the manifest, validate input before effects and validate the real result after execution. This module does not implement hosted auth/admission, permission enforcement, async events, cancellation, resume, idempotency, or business-result guarantees. + +## Acceptance for this SDK slice + +The contract is independently usable for authoring, catalog serialization and +adapter metadata. Its acceptance path is: import the built public SDK, create +an explicit manifest, serialize/reload it against a trusted digest, validate +input/result fixtures and emit schema-equivalent native/MCP descriptors. +`tests/flow-tool-public-api.test.ts` exercises that path in a separate Node +process; `tests/flow-tool-manifest.test.ts` covers malformed schemas, tampering, +resource limits and behavior-free snapshots. These are local contract tests, +not a hosted end-to-end run or evidence of business-result correctness. + +The unchanged package and Linux kernel/SDK CI gates protect existing consumers. +Passing them does not satisfy the RFC's live workload gates. Execution/admission +and a real callable-flow journey remain separate implementation and acceptance +work: they are prerequisites for shipping a callable adapter, not capabilities +that this manifest-only module claims to deliver. diff --git a/packages/sdk/src/flow-tool-manifest.ts b/packages/sdk/src/flow-tool-manifest.ts index de6cc9298..c6c171dbf 100644 --- a/packages/sdk/src/flow-tool-manifest.ts +++ b/packages/sdk/src/flow-tool-manifest.ts @@ -27,6 +27,7 @@ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\. function record(value: JsonValue, fields: readonly string[], at: string): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${at}: expected an object`); if (Object.keys(value).some(key => !fields.includes(key)) || fields.some(key => !Object.hasOwn(value, key))) { + // Unknown field names may themselves be sensitive caller data; do not echo them. throw new TypeError(`${at}: missing or unknown fields`); } return value; @@ -71,6 +72,7 @@ export function parseFlowToolManifest(value: unknown, expectedDigest?: FlowToolD if (data.manifestVersion !== 1 || data.schemaDialect !== FLOW_TOOL_SCHEMA_DIALECT) { throw new TypeError('flow tool manifest: unsupported manifest version or schema dialect'); } + // Identity verification must never trim, case-fold or otherwise normalize metadata. const parsed = createFlowToolManifest(Object.fromEntries(FIELDS.map(key => [key, data[key]])) as unknown as FlowToolDeclarationV1); if (data.digest !== parsed.digest || (expectedDigest !== undefined && parsed.digest !== expectedDigest)) { throw new TypeError('flow tool manifest: digest mismatch'); @@ -86,7 +88,7 @@ function validate(value: unknown, schema: FlowToolObjectSchema, at: string): Rea const snapshot = snapshotJsonValue(value, at, FLOW_TOOL_LIMITS); const validator = compileFlowToolSchema(schema); if (!validator(snapshot)) { - // Report schema location, never data or the provider's raw response. + // Report only the keyword: instance paths can contain sensitive caller keys. throw new TypeError(`${at}: schema validation failed (${validator.errors?.[0]?.keyword ?? 'invalid'})`); } return snapshot as Readonly>; diff --git a/packages/sdk/src/flow-tool-schema.ts b/packages/sdk/src/flow-tool-schema.ts index 6b0ceeba2..9aea1a7ec 100644 --- a/packages/sdk/src/flow-tool-schema.ts +++ b/packages/sdk/src/flow-tool-schema.ts @@ -45,6 +45,8 @@ function inspect(schema: JsonValue, at: string): void { export function compileFlowToolSchema(schema: FlowToolObjectSchema) { // No coercion, defaults, removal of extras, network retrieval, or ignored formats. + // Deliberately revalidate each supplied manifest; no unbounded global schema cache. + // A future admitted catalog may own a bounded compiled-validator lifecycle. return new Ajv2020({ strict: true, strictRequired: true, allowUnionTypes: true, allErrors: false }) .compile(schema); } diff --git a/packages/sdk/tests/flow-tool-manifest.test.ts b/packages/sdk/tests/flow-tool-manifest.test.ts index d6019b897..efbd7487d 100644 --- a/packages/sdk/tests/flow-tool-manifest.test.ts +++ b/packages/sdk/tests/flow-tool-manifest.test.ts @@ -16,6 +16,7 @@ function declaration(): FlowToolDeclarationV1 { resultSchema: { type: 'object', properties: { verdict: { enum: ['hold', 'pass'] } }, required: ['verdict'], additionalProperties: false }, }; } +// Deliberately cross the static type boundary to exercise hostile runtime input. const unchecked = (value: unknown) => createFlowToolManifest(value as FlowToolDeclarationV1); describe('FlowToolManifestV1', () => { diff --git a/packages/sdk/tests/flow-tool-public-api.test.ts b/packages/sdk/tests/flow-tool-public-api.test.ts new file mode 100644 index 000000000..f5b294d71 --- /dev/null +++ b/packages/sdk/tests/flow-tool-public-api.test.ts @@ -0,0 +1,60 @@ +import { execFileSync } from 'node:child_process'; +import { it, expect } from 'vitest'; + +it('round-trips the manifest contract through the built public SDK without executing a flow', () => { + // The normal SDK test command builds dist first. A separate Node process avoids + // Vitest/source aliases hiding a missing public export or broken emitted import. + const sdkUrl = new URL('../dist/index.js', import.meta.url).href; + const output = execFileSync(process.execPath, ['--input-type=module', '--eval', ` + import assert from 'node:assert/strict'; + import { + createFlowToolManifest, canonicalFlowToolManifest, parseFlowToolManifest, + validateFlowToolInput, validateFlowToolResult, + flowToolFunctionDefinition, flowToolMcpDefinition, + } from ${JSON.stringify(sdkUrl)}; + + const manifest = createFlowToolManifest({ + name: 'review_pull_request', description: 'A hold verdict is not approval.', + flow: { name: 'pr-review', version: '1.0.0', digest: 'sha256:' + 'a'.repeat(64) }, + inputSchema: { + type: 'object', properties: { pr: { type: 'integer', minimum: 1 } }, + required: ['pr'], additionalProperties: false, + }, + resultSchema: { + type: 'object', properties: { verdict: { enum: ['hold', 'pass'] } }, + required: ['verdict'], additionalProperties: false, + }, + }); + const serialized = canonicalFlowToolManifest(manifest); + const restored = parseFlowToolManifest(JSON.parse(serialized), manifest.digest); + assert.deepEqual(restored, manifest); + assert.equal(canonicalFlowToolManifest(restored), serialized); + assert.ok(Object.isFrozen(restored.inputSchema)); + const input = validateFlowToolInput(restored, { pr: 42 }); + assert.deepEqual(input, Object.assign(Object.create(null), { pr: 42 })); + assert.ok(Object.isFrozen(input)); + // This is a supplied result fixture, not the output of an executed flow. + const result = validateFlowToolResult(restored, { verdict: 'hold' }); + assert.deepEqual(result, Object.assign(Object.create(null), { verdict: 'hold' })); + assert.ok(Object.isFrozen(result)); + assert.throws(() => validateFlowToolInput(restored, { pr: '42' }), /schema validation failed/); + assert.throws(() => validateFlowToolResult(restored, { verdict: 'completed' }), /schema validation failed/); + assert.throws(() => parseFlowToolManifest({ ...restored, description: 'changed' }), /digest mismatch/); + assert.throws(() => parseFlowToolManifest(restored, 'sha256:' + '0'.repeat(64)), /digest mismatch/); + + const native = flowToolFunctionDefinition(restored); + const mcp = flowToolMcpDefinition(restored); + assert.deepEqual(native, { + type: 'function', name: restored.name, description: restored.description, + parameters: restored.inputSchema, + }); + assert.deepEqual(mcp, { + name: restored.name, description: restored.description, + inputSchema: restored.inputSchema, outputSchema: restored.resultSchema, + }); + assert.equal('annotations' in mcp, false); + assert.equal('strict' in native, false); + console.log('FLOW_TOOL_PUBLIC_CONTRACT_OK'); + `], { encoding: 'utf8', timeout: 15_000 }); + expect(output.trim()).toBe('FLOW_TOOL_PUBLIC_CONTRACT_OK'); +}, 20_000); From dbf266682ab0785349f686f0cdc4b03bcb75df55 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 24 Sep 2026 07:09:47 +0200 Subject: [PATCH 3/6] feat(sdk): add canonical Flow Tool lifecycle client and adapters --- packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md | 111 +++++++++ packages/sdk/FLOW-TOOLS.md | 68 +++++- packages/sdk/src/flow-tool-adapters.ts | 43 ++++ packages/sdk/src/flow-tool-client.ts | 110 +++++++++ packages/sdk/src/flow-tool-contract.ts | 153 ++++++++++++ packages/sdk/src/flow-tool-http.ts | 133 +++++++++++ packages/sdk/src/flow-tool-wire.ts | 141 +++++++++++ packages/sdk/src/index.ts | 7 + .../sdk/tests/fixtures/flow-tool-api-v1.json | 177 ++++++++++++++ .../sdk/tests/flow-tool-control-fixture.ts | 105 +++++++++ packages/sdk/tests/flow-tool-control.test.ts | 219 ++++++++++++++++++ packages/sdk/tests/flow-tool-http.test.ts | 94 ++++++++ .../sdk/tests/flow-tool-public-api.test.ts | 20 ++ 13 files changed, 1376 insertions(+), 5 deletions(-) create mode 100644 packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md create mode 100644 packages/sdk/src/flow-tool-adapters.ts create mode 100644 packages/sdk/src/flow-tool-client.ts create mode 100644 packages/sdk/src/flow-tool-contract.ts create mode 100644 packages/sdk/src/flow-tool-http.ts create mode 100644 packages/sdk/src/flow-tool-wire.ts create mode 100644 packages/sdk/tests/fixtures/flow-tool-api-v1.json create mode 100644 packages/sdk/tests/flow-tool-control-fixture.ts create mode 100644 packages/sdk/tests/flow-tool-control.test.ts create mode 100644 packages/sdk/tests/flow-tool-http.test.ts diff --git a/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md b/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md new file mode 100644 index 000000000..47720a893 --- /dev/null +++ b/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md @@ -0,0 +1,111 @@ +# RFC-0002 implementation and Cloud handoff + +Baseline: main `e07a190651159a4bd3bfe329a2425aeb7cf949f6`; PR #568. +The RFC is the target, not a claim that its hosted acceptance gates have passed. + +## Spec-to-code gap matrix and dependency order + +| RFC requirement | Existing foundation | This Flows slice | Remaining owner/dependency | +| --- | --- | --- | --- | +| Explicit immutable schemas | FlowToolManifestV1, bounded snapshots, canonical digest | Keep backwards-compatible manifest APIs; validate catalog and lifecycle envelopes | Surface authoring headers and signed bundle publication binding | +| Scoped discovery | No hosted tool registry | Validated canonical catalog; native/MCP/action views of the same selected revisions | Cloud must filter tenant/workspace/deployment/grants before response | +| Digest-pinned invoke | Local bundle verification; source-based Cloud run is not admission | Mandatory manifest/deployment/flow binding and canonical input hash; no source fallback | Cloud verifies signer trust, sealed bundle and embedded manifest | +| Durable idempotency | Kernel effect keys are not API admission keys | Caller-owned key preserved by every adapter; explicit conflict/ambiguous transport outcomes | Cloud atomic unique scope/key + input-hash ledger and durable launch reconciliation | +| Async lifecycle | Journal protocol run/status/cancel/resume | Stable receipt, strict status/result, replay cursor, bounded observation request, human answer/cancel/resume contracts | Cloud journal projection, durable command authorization and execution | +| Terminal verdict/evidence | Run completion reason, journal, spend | Separate platform reason/business enum/result schema; closed redacted evidence references | Trusted result producer, evidence ACL/retention/redaction and journal-digest calculation | +| Scope/budget enforcement | Declarations are not enforcement | No caller-selected identity, deployment authority or budget in model arguments; read-only catalog v1 only | Cloud gate-8 credentials/effects enforcement; deny write-enabled tools | +| Adapter parity | Descriptor-only functions | Native, MCP and Relay-compatible action handlers call the same client | Actual authenticated server/session/action registration and provider-specific schemas | +| Restart/crash proof | Kernel crash suite | Contract transport conformance with persisted test fixture and client reconstruction | Real admission/process/worker/effect crash tests; live pilot proof | + +Dependency order: (1) strict shared wire contract and schema/binding validators; +(2) explicit authenticated transport and canonical client; (3) adapters with +host-owned idempotency metadata; (4) negative/parity/replay conformance tests; +(5) Cloud admission and projection implementation; (6) non-production read-only +pilot plus RFC acceptance gates. PR remains draft until its declared acceptance +is satisfied. Unit/fixture tests do not complete the hosted RFC. + +## Cloud implementation contract (not implemented by this SDK) + +Implement the versioned `/api/v1/flow-tools` and `/api/v1/flow-runs` API consumed by +`FlowToolClient`, using the exact exported schemas/types. No existing source-run +endpoint may be substituted. A missing route is an unsupported deployment, +not permission to weaken provenance. + +Shared wire fixture: `tests/fixtures/flow-tool-api-v1.json` (explicitly +`fixture_only:true`). Both repositories should validate these exact catalog, +request, receipt, terminal, evidence and event bytes/hashes. Routes use Next.js +path segments: `/api/v1/flow-tools/{name}/invoke`, run `/cancel`, `/resume`, and +`/human/{waitId}/answer`; this is an intentional routable spelling of the RFC's +conceptual colon commands. Every POST carries `Idempotency-Key`; every JSON +envelope carries `api_version:1`. SSE uses sequence as `id`, type as `event`, +JSON as `data`, and `Last-Event-ID` as an exclusive resume cursor. Non-2xx errors +are `{api_version:1,code}`; no server error prose is reflected to a model. + +Supported HTTP mappings: 400/422 invalid_contract, 401/403 not_authorized, +404 not_found, 409 idempotency_conflict, 501 unsupported, other non-2xx +unavailable. Transport failure is separately ambiguous. Run `state:cancelled` +maps to terminal reason `canceled`, matching the existing SDK/kernel reason. + +The current bundle executor does not admit authored/agent/LLM bundles. The +build probe rejects nonempty authored headers and the local digest runner only +supports declarative deterministic specs without assets/requirements/triggers. +An exact-spec Cloud executor may support a tiny explicitly enforced no-effect +command allowlist (for example literal `true`/`false`) as a control-plane smoke. +That is not the RFC Babysitter pilot. A constant result mapping must be clearly +declared and bound to immutable reviewed policy; completing `true` is not +evidence of PR review or a computed business verdict. General authored artifacts, +surface headers, signed publication and journaled business-output binding remain +real implementation dependencies, not capabilities provided by this client. + +1. Authenticate each request and resolve tenant, workspace, principal and + deployment server-side. Filter disabled/unauthorized revisions before + discovery; reauthorize invocation and every status/events/evidence/command + request. Never trust a principal/tenant supplied in model input. Model + arguments are exclusively the manifest's input schema. Bind the bearer to + this API audience; use dedicated scoped credentials, not Relay workspace keys. +2. Verify the sealed bundle digest/signature against trusted publication keys, + the embedded manifest digest, allowed deployment revision and read-only + policy. Validate input before worker launch or credential retrieval. Reject + unmetered deployments if policy requires a dollar ceiling. Include the + effective non-escalatable budget/effects in the catalog policy. +3. In one transaction claim `(tenant, deployment, flow_digest, principal, + idempotency_key)`, storing canonical input digest, manifest identity, run ID + and a durable launch intent. Same input returns the original run; different + input returns 409 `idempotency_conflict`. Reconcile the outbox after crash; + never acknowledge acceptance before durable commit, never launch before + durable intent, and deduplicate run creation when launch acknowledgment is + lost. Client-side maps/locks are not a substitute. +4. Drive existing tenant cell journal protocol, not a second workflow engine. + Persist mapping from admission ID to kernel run. Status, journal sequence + events and terminal envelopes must repeat pinned identity. SSE resumes + strictly after Last-Event-ID; gaps in the public stream may reflect redacted + journal entries, but duplicate/out-of-order visible IDs are invalid. +5. Project only allowlisted public event fields. Produce the flow-defined + result/verdict from a trusted completion binding, not a model sentence or + successful HTTP response. Validate the result schema, distinct failure + reasons, gate evidence references and spend. A failed/cancelled run cannot + carry a fabricated successful business verdict. Evidence refs are opaque, + redacted, tenant-scoped and retention-controlled; retrieval rechecks ACLs. +6. Journal cancel requests durably; resume only existing runnable runs and + never restart terminal runs. Human answers are yes/no in v1, authorized by + the authenticated human principal, with durable answer-idempotency and + audit. Do not trust `answered_by` from a model. Replay of a command key + cannot apply another action or changed answer. +7. Bound synchronous observation to 25 seconds and return the existing + receipt if unfinished. An observer disconnect/request timeout does not + cancel the run. SDK transport errors explicitly leave admission unknown. + +## Evidence needed before readiness + +The contract test backend is a fixture: persistence/reconstruction proves the +client does not own run state, not that Cloud has an atomic ledger. No production +Cloud route is contacted by tests. Linux full SDK/kernel CI protects regressions +but does not prove live-model, provider-effect or Cloud admission behavior. + +Required external proof: dedicated non-production repository/credentials; +signed build and manifest admission; unauthorized discovery/read/write denial; +concurrent and interrupted admission retries; restart during work/human wait; +monotonic reconnect events; one declared external effect; redaction/ACL checks; +negative and positive Babysitter terminal verdicts; adapter parity; redacted +observer link and exact journal/artifact digests. No merge-ready or hosted +guarantee claim is made until that evidence and independent review exist. diff --git a/packages/sdk/FLOW-TOOLS.md b/packages/sdk/FLOW-TOOLS.md index 3ea06e04f..fefb449bf 100644 --- a/packages/sdk/FLOW-TOOLS.md +++ b/packages/sdk/FLOW-TOOLS.md @@ -85,13 +85,13 @@ is enabled, and no constraint is silently rewritten. Neither definition carries credentials or asserts read-only, destructive, idempotent or business-success annotations. Keep its manifest identity in the adapter's trusted binding. -There is deliberately **no MCP server or invocation handler** in this slice. +The descriptor-only functions above deliberately have no invocation handler. The authored runtime currently returns a completion reason and journal-step references, not this flow-defined result object or a verified bundle-admission receipt. A callable adapter must first bind verified execution to the manifest, validate input before effects and validate the real result after execution. -This module does not implement hosted auth/admission, permission enforcement, -async events, cancellation, resume, idempotency, or business-result guarantees. +The new control-plane client below supplies transport and validation, not the +hosted admission ledger, permission enforcement or execution implementation. ## Acceptance for this SDK slice @@ -107,5 +107,63 @@ not a hosted end-to-end run or evidence of business-result correctness. The unchanged package and Linux kernel/SDK CI gates protect existing consumers. Passing them does not satisfy the RFC's live workload gates. Execution/admission and a real callable-flow journey remain separate implementation and acceptance -work: they are prerequisites for shipping a callable adapter, not capabilities -that this manifest-only module claims to deliver. +work: they are prerequisites for shipping an enabled callable product, not +capabilities that this SDK's transport implementation claims to provide. + +## Canonical control-plane client and call adapters + +`FlowToolClient` speaks one versioned, digest-pinned contract. It has no default +production endpoint and never falls back to `runInCloud` source submission. +The deployment must implement the explicit contract in +[FLOW-TOOLS-IMPLEMENTATION.md](./FLOW-TOOLS-IMPLEMENTATION.md). An unsupported +executor must refuse before admission; a metadata match is not provenance. + +```ts +import { FlowToolClient, createFlowToolHttpTransport, createFlowToolAdapters } from '@relayflows/sdk'; + +async function review(apiUrl: string, scopedToken: string, operationKey: string) { + const client = new FlowToolClient(createFlowToolHttpTransport({ apiUrl, token: scopedToken })); + const catalog = await client.discover(); // server filters scope before returning metadata + const selected = catalog.tools.find(tool => tool.manifest.name === 'review_pr'); + if (!selected) throw new Error('No authorized revision'); + const adapters = createFlowToolAdapters(client, selected); + // The host persists this key for the logical operation; it is not a model argument. + const receipt = await adapters.native.call({ pr: 42 }, { idempotencyKey: operationKey }); + if (receipt.terminal === null) return receipt; // accepted is not completed + return receipt.terminal; // inspect business_verdict even when terminal_reason is success +} +``` + +Native `call`, MCP `call` and action `invoke` pass the same input and host-owned +operation metadata to that client. MCP returns the canonical run in +`structuredContent` and a JSON text fallback; its output schema is the **run +envelope**, not the eventual business-result schema. Existing metadata APIs +remain unchanged. A host registers these handlers with its authenticated +native/MCP/Relay session; this package does not start a public server or register +Relay actions automatically. Provider-specific schema translation remains the +host's responsibility. Never mistake a Relay dispatch acknowledgment for the +canonical handler result. + +`status`, `events`, `evidence`, `cancel`, `resume` and yes/no `answer` all require +the selected immutable entry and an accepted receipt. Persist that binding and +the last validated event sequence to reconnect after a client restart; the +server reauthorizes every operation, including reads. Do not re-resolve an +alias to a newer revision for an old run. Events reject backwards/duplicate IDs +and identity changes. Run envelopes reject incomplete terminal claims, invalid +result schemas, unknown verdicts and mutation of a previously terminal result. +The server still owns trusted result production, redaction and evidence ACLs. + +Async is the default. Sync supplies a server observation bound of at most +25 seconds and may still return an unfinished receipt. Transport failure means +the outcome may be unknown: retry only with the same logical operation key. +No implicit retry, background polling or cancellation is performed. All POST +commands need stable idempotency keys; human identity is derived from server +authentication, never an `answered_by` model argument. Observation aborts and +SSE reconnects do not cancel the run. + +Protocol v1 intentionally accepts only read-only catalog entries and `*:read` +effect labels. Labels are not effect enforcement; enabling a deployment still +requires the server's real scope controls. Hosted crash/effect proof, generic +MCP server registration, signed publication/authoring integration and the live +read-only pilot remain acceptance work. See the gap matrix, not test-fixture +success, for the current release blockers. diff --git a/packages/sdk/src/flow-tool-adapters.ts b/packages/sdk/src/flow-tool-adapters.ts new file mode 100644 index 000000000..370cfbf0a --- /dev/null +++ b/packages/sdk/src/flow-tool-adapters.ts @@ -0,0 +1,43 @@ +import { canonicalize } from './canonical.js'; +import { FlowToolClient, type FlowToolInvocationOptions } from './flow-tool-client.js'; +import { flowToolFunctionDefinition } from './flow-tool-definitions.js'; +import { FLOW_TOOL_RUN_SCHEMA, type FlowToolCatalogEntryV1 } from './flow-tool-contract.js'; +import { parseFlowToolEntry } from './flow-tool-wire.js'; + +/** + * One selected, authorized revision; no alternate execution path. Host/session + * code supplies stable operation metadata separately from model tool arguments. + * Register only after discovery with this same authenticated client. The server + * MUST reauthorize; possession of this object is not an authorization token. + */ +export function createFlowToolAdapters(client: FlowToolClient, selected: FlowToolCatalogEntryV1) { + const entry = parseFlowToolEntry(selected); + const invoke = (input: unknown, operation: FlowToolInvocationOptions) => client.invoke(entry, input, operation); + return Object.freeze({ + native: Object.freeze({ definition: flowToolFunctionDefinition(entry.manifest), call: invoke }), + mcp: Object.freeze({ + // Do not reuse the legacy business-result-only metadata descriptor here: + // tools/call returns an async run envelope, not the eventual result object. + definition: Object.freeze({ + name: entry.manifest.name, description: entry.manifest.description, + inputSchema: entry.manifest.inputSchema, outputSchema: FLOW_TOOL_RUN_SCHEMA, + }), + async call(input: unknown, operation: FlowToolInvocationOptions) { + const run = await invoke(input, operation); + return { + structuredContent: Object.freeze({ ...run }), + content: [{ type: 'text' as const, text: canonicalize(run) }], + }; + }, + }), + // Relay action registration can use this schema/handler. An action dispatch + // acknowledgment is not this return value and must never become completion. + action: Object.freeze({ + definition: Object.freeze({ + name: entry.manifest.name, description: entry.manifest.description, + inputSchema: entry.manifest.inputSchema, outputSchema: FLOW_TOOL_RUN_SCHEMA, + }), + invoke, + }), + }); +} diff --git a/packages/sdk/src/flow-tool-client.ts b/packages/sdk/src/flow-tool-client.ts new file mode 100644 index 000000000..5a7872852 --- /dev/null +++ b/packages/sdk/src/flow-tool-client.ts @@ -0,0 +1,110 @@ +import { canonicalize } from './canonical.js'; +import { snapshotJsonValue } from './json-value.js'; +import { FLOW_TOOL_LIMITS } from './flow-tool-schema.js'; +import { validateFlowToolInput } from './flow-tool-manifest.js'; +import { FlowToolError, type FlowToolCatalogEntryV1, type FlowToolRunV1, type FlowToolEventV1 } from './flow-tool-contract.js'; +import { parseFlowToolCatalog, parseFlowToolEntry, parseFlowToolRun, parseFlowToolEvent, parseFlowToolEvidence, flowToolInputDigest, toolId } from './flow-tool-wire.js'; + +export interface FlowToolRequest { + readonly method: 'GET' | 'POST'; + readonly path: string; + readonly body?: unknown; + readonly idempotencyKey?: string; +} +/** + * Authenticated control-plane boundary. Implementations must authorize every + * operation and durably admit/deduplicate runs; the client owns no run ledger. + * A test transport is not a hosted implementation. No source-submission fallback. + */ +export interface FlowToolTransport { + request(request: FlowToolRequest): Promise; + events(path: string, after: number): AsyncIterable; +} +export interface FlowToolInvocationOptions { + /** Host-owned operation identity; preserve across reconnect/retry, never generate per retry. */ + readonly idempotencyKey: string; + readonly mode?: 'async' | 'sync'; + /** Server-side observation bound only, not an execution timeout or cancellation. */ + readonly waitMs?: number; +} +export function flowToolOperationKey(value: unknown): asserts value is string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-][A-Za-z0-9_.:-]{0,127}$/.test(value)) throw new FlowToolError('invalid_contract'); +} + +export class FlowToolClient { + constructor(private readonly transport: FlowToolTransport) {} + + async discover() { + return parseFlowToolCatalog(await this.transport.request({ method: 'GET', path: '/api/v1/flow-tools' })); + } + + async invoke(selected: FlowToolCatalogEntryV1, input: unknown, options: FlowToolInvocationOptions): Promise { + const entry = parseFlowToolEntry(selected); + // Schema validation precedes ALL transport calls, including credential access. + const validated = validateFlowToolInput(entry.manifest, input); + flowToolOperationKey(options.idempotencyKey); + const mode = options.mode ?? 'async'; + const waitMs = options.waitMs ?? 0; + if (!['async', 'sync'].includes(mode) || !Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 25_000 + || (mode === 'async' && waitMs !== 0)) throw new FlowToolError('invalid_contract'); + const inputDigest = flowToolInputDigest(validated); + const body = { + api_version: 1, flow: `${entry.manifest.flow.name}@${entry.manifest.flow.digest}`, + deployment_id: entry.deployment_id, manifest_digest: entry.manifest.digest, + input: validated, input_digest: inputDigest, mode, wait_ms: waitMs, + }; + const run = parseFlowToolRun(await this.transport.request({ + method: 'POST', path: `/api/v1/flow-tools/${entry.manifest.name}/invoke`, body, idempotencyKey: options.idempotencyKey, + }), entry); + if (run.input_digest !== inputDigest) throw new FlowToolError('invalid_contract'); + return run; + } + + async status(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1): Promise { + const entry = parseFlowToolEntry(selected), run = parseFlowToolRun(receipt, entry); + return parseFlowToolRun(await this.transport.request({ method: 'GET', path: run.status_url }), entry, run); + } + + async *events(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, after = 0): AsyncIterable { + const run = parseFlowToolRun(receipt, parseFlowToolEntry(selected)); + if (!Number.isSafeInteger(after) || after < 0) throw new FlowToolError('invalid_contract'); + let cursor = after; + for await (const raw of this.transport.events(run.events_url, after)) { + const event = parseFlowToolEvent(raw, run, cursor); + cursor = event.sequence; + yield event; + if (event.type === 'run.terminal') return; + } + } + + async evidence(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1) { + const run = parseFlowToolRun(receipt, parseFlowToolEntry(selected)); + const raw = snapshotJsonValue(await this.transport.request({ method: 'GET', path: run.evidence_url }), 'flow tool evidence', FLOW_TOOL_LIMITS); + if (raw === null || typeof raw !== 'object' || Array.isArray(raw) + || Object.keys(raw).sort().join(',') !== 'api_version,evidence,run_id' + || raw.api_version !== 1 || raw.run_id !== run.run_id) throw new FlowToolError('invalid_contract'); + const evidence = parseFlowToolEvidence(raw.evidence, run.flow_digest); + if (run.terminal !== null && canonicalize(evidence) !== canonicalize(run.terminal.evidence)) throw new FlowToolError('invalid_contract'); + return evidence; + } + + cancel(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, key: string) { + return this.command(selected, receipt, '/cancel', key, { api_version: 1 }); + } + resume(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, key: string) { + return this.command(selected, receipt, '/resume', key, { api_version: 1 }); + } + answer(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, waitId: string, approved: boolean, key: string) { + toolId(waitId); + if (typeof approved !== 'boolean') throw new FlowToolError('invalid_contract'); + // No answered_by claim: the server derives/audits the authenticated human actor. + return this.command(selected, receipt, `/human/${waitId}/answer`, key, { api_version: 1, input: { approved } }); + } + private async command(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, suffix: string, key: string, body: unknown) { + const entry = parseFlowToolEntry(selected), run = parseFlowToolRun(receipt, entry); + flowToolOperationKey(key); + return parseFlowToolRun(await this.transport.request({ + method: 'POST', path: `${run.status_url}${suffix}`, idempotencyKey: key, body, + }), entry, run); + } +} diff --git a/packages/sdk/src/flow-tool-contract.ts b/packages/sdk/src/flow-tool-contract.ts new file mode 100644 index 000000000..e85c501fa --- /dev/null +++ b/packages/sdk/src/flow-tool-contract.ts @@ -0,0 +1,153 @@ +import type { FlowToolDigest, FlowToolManifestV1 } from './flow-tool-manifest.js'; +import type { FlowToolJson, FlowToolObjectSchema } from './flow-tool-schema.js'; +import type { Spend } from './run-state.js'; +import { snapshotJsonValue } from './json-value.js'; +import { FLOW_TOOL_LIMITS } from './flow-tool-schema.js'; + +export const FLOW_TOOL_API_VERSION = 1 as const; +export const FLOW_TOOL_TERMINAL_REASONS = [ + 'success', 'gate_failed', 'model_failed', 'agent_failed', 'budget_exceeded', + 'human_rejected', 'human_timeout', 'canceled', 'execution_failed', +] as const; +export type FlowToolTerminalReason = typeof FLOW_TOOL_TERMINAL_REASONS[number]; +export type FlowToolRunState = 'accepted' | 'running' | 'parked' | 'cancelling' | 'completed' | 'failed' | 'cancelled'; + +/** Operator-owned, server-authorized read-only revision; never generated from run input. */ +export interface FlowToolCatalogEntryV1 { + readonly manifest: FlowToolManifestV1; + readonly deployment_id: string; + readonly read_only: true; + readonly effects: readonly string[]; + readonly requires_human: readonly string[]; + readonly business_verdicts: readonly string[]; + readonly budget: Readonly<{ max_tokens: number; max_dollars: string | null; max_wallclock_ms: number }>; +} +export interface FlowToolCatalogV1 { + readonly api_version: 1; + readonly tools: readonly FlowToolCatalogEntryV1[]; +} +export interface FlowToolEvidenceV1 { + readonly journal_digest: FlowToolDigest; + readonly flow_digest: FlowToolDigest; + readonly artifacts: readonly Readonly<{ name: string; ref: string; media_type: string }>[]; + readonly redacted_transcript_refs: readonly string[]; +} +export interface FlowToolTerminalV1 { + readonly terminal_reason: FlowToolTerminalReason; + readonly business_verdict: string | null; + readonly result: Readonly> | null; + readonly gates: readonly Readonly<{ name: string; status: 'pass' | 'fail' | 'skipped'; evidence_ref: string }>[]; + readonly evidence: FlowToolEvidenceV1; + readonly spend: Readonly; +} +/** Accepted is durable admission, never successful execution; terminal is null until finished. */ +export interface FlowToolRunV1 { + readonly api_version: 1; + readonly accepted: true; + readonly run_id: string; + readonly tool_name: string; + readonly deployment_id: string; + readonly manifest_digest: FlowToolDigest; + readonly flow_digest: FlowToolDigest; + readonly input_digest: FlowToolDigest; + readonly state: FlowToolRunState; + readonly sequence: number; + readonly status_url: string; + readonly events_url: string; + readonly evidence_url: string; + readonly cancel_url: string; + readonly resume_url: string; + readonly terminal: FlowToolTerminalV1 | null; +} +export interface FlowToolEventV1 { + readonly api_version: 1; + readonly run_id: string; + readonly flow_digest: FlowToolDigest; + readonly sequence: number; + readonly type: 'run.accepted' | 'run.state_changed' | 'step.completed' | 'human.required' | 'run.terminal'; + readonly state: FlowToolRunState; + readonly step_id?: string; + readonly wait_id?: string; + readonly terminal_reason?: FlowToolTerminalReason; +} +export interface FlowToolInvokeRequestV1 { + readonly api_version: 1; + readonly flow: string; + readonly deployment_id: string; + readonly manifest_digest: FlowToolDigest; + readonly input: Readonly>; + /** Assertion only: admission MUST recompute this from schema-validated canonical input. */ + readonly input_digest: FlowToolDigest; + readonly mode: 'async' | 'sync'; + readonly wait_ms: number; +} +export type FlowToolFailureCode = 'invalid_contract' | 'not_authorized' | 'not_found' + | 'idempotency_conflict' | 'unsupported' | 'unavailable' | 'transport_error'; +export class FlowToolError extends Error { + constructor(readonly code: FlowToolFailureCode) { + // Never interpolate remote bodies, model input, bearer tokens or transport errors. + super(`Flow Tool ${code}${code === 'transport_error' ? ': admission or command outcome may be unknown; observation failure does not cancel a run' : ''}`); + this.name = 'FlowToolError'; + } +} + +const text = { type: 'string', minLength: 1, maxLength: 128 }; +const digest = { type: 'string', minLength: 71, maxLength: 71 }; +const integer = { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }; +const positive = { ...integer, minimum: 1 }; +const strings = { type: 'array', items: text, maxItems: 64, uniqueItems: true }; +const state = { enum: ['accepted', 'running', 'parked', 'cancelling', 'completed', 'failed', 'cancelled'] }; +function object(properties: Record, required = Object.keys(properties)) { + return snapshotJsonValue({ type: 'object', properties, required, additionalProperties: false }, + 'flow tool protocol schema', FLOW_TOOL_LIMITS) as FlowToolObjectSchema; +} +export const FLOW_TOOL_EVIDENCE_SCHEMA = object({ + journal_digest: digest, flow_digest: digest, + artifacts: { type: 'array', maxItems: 128, items: object({ name: text, ref: text, media_type: text }) }, + redacted_transcript_refs: strings, +}) as FlowToolObjectSchema; +const terminal = object({ + terminal_reason: { enum: FLOW_TOOL_TERMINAL_REASONS }, + business_verdict: { type: ['string', 'null'], minLength: 1, maxLength: 128 }, + result: { type: ['object', 'null'] }, + gates: { type: 'array', maxItems: 128, items: object({ name: text, status: { enum: ['pass', 'fail', 'skipped'] }, evidence_ref: text }) }, + evidence: FLOW_TOOL_EVIDENCE_SCHEMA, + spend: object({ tokens_in: integer, tokens_out: integer, dollars: text, dollars_unmetered: { type: 'boolean' } }), +}); +/** Async adapters return this envelope, NOT the flow's eventual business-result schema. */ +export const FLOW_TOOL_RUN_SCHEMA = object({ + api_version: { const: 1 }, accepted: { const: true }, run_id: text, tool_name: text, + deployment_id: text, manifest_digest: digest, flow_digest: digest, input_digest: digest, + state, sequence: integer, + status_url: { type: 'string' }, events_url: { type: 'string' }, evidence_url: { type: 'string' }, + cancel_url: { type: 'string' }, resume_url: { type: 'string' }, + terminal: { anyOf: [{ type: 'null' }, terminal] }, +}) as FlowToolObjectSchema; +export const FLOW_TOOL_CATALOG_SCHEMA = object({ + api_version: { const: 1 }, + tools: { type: 'array', maxItems: 64, items: object({ + manifest: { type: 'object' }, deployment_id: text, read_only: { const: true }, + effects: strings, requires_human: strings, + business_verdicts: { ...strings, minItems: 1 }, + budget: object({ max_tokens: positive, max_dollars: { type: ['string', 'null'] }, max_wallclock_ms: positive }), + }) }, +}) as FlowToolObjectSchema; +export const FLOW_TOOL_EVENT_SCHEMA = object({ + api_version: { const: 1 }, run_id: text, flow_digest: digest, sequence: positive, + type: { enum: ['run.accepted', 'run.state_changed', 'step.completed', 'human.required', 'run.terminal'] }, + state, step_id: text, wait_id: text, terminal_reason: { enum: FLOW_TOOL_TERMINAL_REASONS }, +}, ['api_version', 'run_id', 'flow_digest', 'sequence', 'type', 'state']) as FlowToolObjectSchema; + +export const FLOW_TOOL_INVOKE_SCHEMA = object({ + api_version: { const: 1 }, flow: { type: 'string', minLength: 73, maxLength: 200 }, + deployment_id: text, manifest_digest: digest, input_digest: digest, + input: { type: 'object' }, mode: { enum: ['async', 'sync'] }, + wait_ms: { type: 'integer', minimum: 0, maximum: 25000 }, +}) as FlowToolObjectSchema; +export const FLOW_TOOL_COMMAND_SCHEMA = object({ api_version: { const: 1 } }) as FlowToolObjectSchema; +export const FLOW_TOOL_ANSWER_SCHEMA = object({ + api_version: { const: 1 }, input: object({ approved: { type: 'boolean' } }), +}) as FlowToolObjectSchema; +export const FLOW_TOOL_ERROR_SCHEMA = object({ + api_version: { const: 1 }, code: { enum: ['invalid_contract', 'not_authorized', 'not_found', 'idempotency_conflict', 'unsupported', 'unavailable'] }, +}) as FlowToolObjectSchema; diff --git a/packages/sdk/src/flow-tool-http.ts b/packages/sdk/src/flow-tool-http.ts new file mode 100644 index 000000000..1858ccf42 --- /dev/null +++ b/packages/sdk/src/flow-tool-http.ts @@ -0,0 +1,133 @@ +import { canonicalize } from './canonical.js'; +import { cloudConnection } from './cloud-http.js'; +import { FlowToolError } from './flow-tool-contract.js'; +import { flowToolOperationKey, type FlowToolRequest, type FlowToolTransport } from './flow-tool-client.js'; + +export interface FlowToolHttpOptions { + /** Explicit trusted HTTPS API origin/base path. No implicit production endpoint. */ + readonly apiUrl: string; + /** Dedicated scoped bearer; never included in definitions, bodies, errors or persisted state. */ + readonly token: string; + readonly requestTimeoutMs?: number; + readonly signal?: AbortSignal; + /** Test/integration seam; the default is the standard fetch implementation. */ + readonly fetch?: typeof fetch; +} + +function pathIsSafe(path: string): boolean { + return /^\/api\/v1\/flow-tools(?:\/[A-Za-z_][A-Za-z0-9_-]{0,63}\/invoke)?$/.test(path) + || /^\/api\/v1\/flow-runs\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}(?:\/(?:events|evidence|cancel|resume)|\/human\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\/answer)?$/.test(path); +} +async function* chunks(response: Response, maximumBytes: number): AsyncIterable { + if (!response.body) throw new FlowToolError('invalid_contract'); + const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true }); + let size = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > maximumBytes) throw new FlowToolError('invalid_contract'); + yield decoder.decode(chunk.value, { stream: true }); + } + yield decoder.decode(); + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } +} +function refuse(status: number): never { + const code = status === 400 || status === 422 ? 'invalid_contract' : status === 401 || status === 403 ? 'not_authorized' : status === 404 ? 'not_found' + : status === 409 ? 'idempotency_conflict' : status === 501 ? 'unsupported' : 'unavailable'; + throw new FlowToolError(code); +} +function safeError(error: unknown): never { + if (error instanceof FlowToolError) throw error; + throw new FlowToolError('transport_error'); +} + +/** No automatic retry; the owner can repeat the same operation key after an ambiguous failure. */ +export function createFlowToolHttpTransport(options: FlowToolHttpOptions): FlowToolTransport { + // Reuse existing HTTPS/token validation, but never fall back to its ambient login store. + if (typeof options.apiUrl !== 'string' || typeof options.token !== 'string') throw new FlowToolError('invalid_contract'); + let connection: { baseUrl: string; token: string }; + try { connection = cloudConnection({ apiUrl: options.apiUrl, token: options.token }); } + catch { throw new FlowToolError('not_authorized'); } + const timeout = options.requestTimeoutMs ?? 30_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new FlowToolError('invalid_contract'); + const fetcher = options.fetch ?? globalThis.fetch; + async function response(path: string, init: RequestInit, accept: string): Promise { + if (!pathIsSafe(path)) throw new FlowToolError('invalid_contract'); + const signal = AbortSignal.any([AbortSignal.timeout(timeout), ...(options.signal ? [options.signal] : [])]); + const result = await fetcher(`${connection.baseUrl}${path}`, { + ...init, redirect: 'error', signal, + headers: { ...init.headers, Authorization: `Bearer ${connection.token}`, Accept: accept }, + }); + if (!result.ok) { await result.body?.cancel(); refuse(result.status); } + if (result.headers.get('content-type')?.toLowerCase().split(';')[0]?.trim() !== accept) { + await result.body?.cancel(); throw new FlowToolError('invalid_contract'); + } + return result; + } + return { + async request(request: FlowToolRequest): Promise { + try { + if (request.idempotencyKey !== undefined) flowToolOperationKey(request.idempotencyKey); + if (request.method === 'POST' && request.idempotencyKey === undefined) throw new FlowToolError('invalid_contract'); + const result = await response(request.path, { + method: request.method, + headers: { ...(request.idempotencyKey === undefined ? {} : { 'Idempotency-Key': request.idempotencyKey }), 'Content-Type': 'application/json' }, + ...(request.body === undefined ? {} : { body: canonicalize(request.body) }), + }, 'application/json'); + let body = ''; + for await (const chunk of chunks(result, 262144)) body += chunk; + try { return JSON.parse(body); } catch { throw new FlowToolError('invalid_contract'); } + } catch (error) { return safeError(error); } + }, + async *events(path: string, after: number): AsyncIterable { + try { + if (!path.endsWith('/events') || !Number.isSafeInteger(after) || after < 0) throw new FlowToolError('invalid_contract'); + const result = await response(path, { method: 'GET', headers: { 'Last-Event-ID': String(after) } }, 'text/event-stream'); + let buffer = '', frame: string[] = [], frameSize = 0; + // Each bounded subscription may reconnect with its last validated cursor. + for await (const chunk of chunks(result, 4 * 1024 * 1024)) { + buffer += chunk; + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ''); + buffer = buffer.slice(newline + 1); + frameSize += line.length; + if (frameSize > 65536) throw new FlowToolError('invalid_contract'); + if (line === '') { + const event = parseFrame(frame); + frame = []; frameSize = 0; + if (event !== undefined) yield event; + } else frame.push(line); + } + if (buffer.length > 65536) throw new FlowToolError('invalid_contract'); + } + if (buffer.length || frame.some(line => !line.startsWith(':'))) throw new FlowToolError('invalid_contract'); + } catch (error) { safeError(error); } + }, + }; +} + +function parseFrame(lines: string[]): unknown { + const data: string[] = []; + let id: string | undefined, name: string | undefined; + for (const line of lines) { + if (line.startsWith(':')) continue; + const separator = line.indexOf(':'); + const field = separator < 0 ? line : line.slice(0, separator); + const value = separator < 0 ? '' : line.slice(separator + 1).replace(/^ /, ''); + if (field === 'data') data.push(value); + else if (field === 'id' && id === undefined) id = value; + else if (field === 'event' && name === undefined) name = value; + else throw new FlowToolError('invalid_contract'); + } + if (data.length === 0 && id === undefined && name === undefined) return undefined; + if (id === undefined || !/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) throw new FlowToolError('invalid_contract'); + let parsed: unknown; + try { parsed = JSON.parse(data.join('\n')); } catch { throw new FlowToolError('invalid_contract'); } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) + || (parsed as Record).sequence !== Number(id) + || (parsed as Record).type !== name) throw new FlowToolError('invalid_contract'); + return parsed; +} diff --git a/packages/sdk/src/flow-tool-wire.ts b/packages/sdk/src/flow-tool-wire.ts new file mode 100644 index 000000000..8510c6e51 --- /dev/null +++ b/packages/sdk/src/flow-tool-wire.ts @@ -0,0 +1,141 @@ +import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; +import { snapshotJsonValue } from './json-value.js'; +import { compileFlowToolSchema, FLOW_TOOL_LIMITS, type FlowToolObjectSchema } from './flow-tool-schema.js'; +import { parseFlowToolManifest, validateFlowToolInput, validateFlowToolResult, type FlowToolDigest } from './flow-tool-manifest.js'; +import { + FlowToolError, FLOW_TOOL_CATALOG_SCHEMA, FLOW_TOOL_RUN_SCHEMA, FLOW_TOOL_EVIDENCE_SCHEMA, FLOW_TOOL_EVENT_SCHEMA, FLOW_TOOL_INVOKE_SCHEMA, + type FlowToolCatalogV1, type FlowToolCatalogEntryV1, type FlowToolRunV1, type FlowToolEventV1, type FlowToolEvidenceV1, type FlowToolInvokeRequestV1, +} from './flow-tool-contract.js'; + +// Compile only our fixed protocol schemas; never cache caller-controlled schemas globally. +const validators = new Map([FLOW_TOOL_CATALOG_SCHEMA, FLOW_TOOL_RUN_SCHEMA, FLOW_TOOL_EVIDENCE_SCHEMA, FLOW_TOOL_EVENT_SCHEMA, FLOW_TOOL_INVOKE_SCHEMA] + .map(schema => [schema, compileFlowToolSchema(schema)])); +function wire(value: unknown, schema: FlowToolObjectSchema): T { + try { + const snapshot = snapshotJsonValue(value, 'flow tool protocol', FLOW_TOOL_LIMITS); + if (!validators.get(schema)!(snapshot)) throw new FlowToolError('invalid_contract'); + return snapshot as T; + } catch { throw new FlowToolError('invalid_contract'); } +} +export function toolId(value: unknown): asserts value is string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(value)) throw new FlowToolError('invalid_contract'); +} +function digest(value: unknown): asserts value is FlowToolDigest { + if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new FlowToolError('invalid_contract'); +} +function decimal(value: string): void { + if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value)) throw new FlowToolError('invalid_contract'); +} +export function flowToolInputDigest(value: unknown): FlowToolDigest { + return `sha256:${sha256(canonicalize(snapshotJsonValue(value, 'flow tool input', FLOW_TOOL_LIMITS)))}`; +} +export function parseFlowToolCatalog(value: unknown): FlowToolCatalogV1 { + const catalog = wire(value, FLOW_TOOL_CATALOG_SCHEMA); + const names = new Set(); + for (const entry of catalog.tools) { + try { parseFlowToolManifest(entry.manifest); } catch { throw new FlowToolError('invalid_contract'); } + toolId(entry.deployment_id); + if (names.has(entry.manifest.name)) throw new FlowToolError('invalid_contract'); + names.add(entry.manifest.name); + for (const name of [...entry.business_verdicts, ...entry.requires_human]) toolId(name); + // Policy labels are not enforcement. The server must also bind read_only to + // effective capabilities; this v1 client will not accept a write-enabled entry. + if (entry.effects.some(effect => !/^[a-z][a-z0-9_-]*:read$/.test(effect))) throw new FlowToolError('invalid_contract'); + if (entry.budget.max_dollars !== null) { + if (entry.budget.max_dollars.length > 128) throw new FlowToolError('invalid_contract'); + decimal(entry.budget.max_dollars); + } + } + return catalog; +} +export function parseFlowToolEntry(value: unknown): FlowToolCatalogEntryV1 { + return parseFlowToolCatalog({ api_version: 1, tools: [value] }).tools[0]!; +} +/** Stable discovery serialization independent of input key or catalog ordering. */ +export function canonicalFlowToolCatalog(value: unknown): string { + const catalog = parseFlowToolCatalog(value); + return canonicalize({ api_version: 1, tools: [...catalog.tools].sort((a, b) => + a.manifest.name < b.manifest.name ? -1 : a.manifest.name > b.manifest.name ? 1 : 0) }); +} +/** Shared admission validation; this proves a contract binding, not authorization. */ +export function parseFlowToolInvocation(value: unknown, selected: FlowToolCatalogEntryV1): FlowToolInvokeRequestV1 { + const entry = parseFlowToolEntry(selected); + const request = wire(value, FLOW_TOOL_INVOKE_SCHEMA); + if (request.flow !== `${entry.manifest.flow.name}@${entry.manifest.flow.digest}` + || request.deployment_id !== entry.deployment_id || request.manifest_digest !== entry.manifest.digest + || (request.mode === 'async' && request.wait_ms !== 0)) throw new FlowToolError('invalid_contract'); + let input: unknown; + try { input = validateFlowToolInput(entry.manifest, request.input); } catch { throw new FlowToolError('invalid_contract'); } + if (flowToolInputDigest(input) !== request.input_digest) throw new FlowToolError('invalid_contract'); + return request; +} +export function parseFlowToolEvidence(value: unknown, flowDigest: FlowToolDigest): FlowToolEvidenceV1 { + const evidence = wire(value, FLOW_TOOL_EVIDENCE_SCHEMA); + digest(evidence.journal_digest); + if (evidence.flow_digest !== flowDigest) throw new FlowToolError('invalid_contract'); + const refs = new Set(); + for (const artifact of evidence.artifacts) { + toolId(artifact.name); toolId(artifact.ref); + if (refs.has(artifact.ref) || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(artifact.media_type)) throw new FlowToolError('invalid_contract'); + refs.add(artifact.ref); + } + evidence.redacted_transcript_refs.forEach(toolId); + return evidence; +} +export function flowToolRunLinks(runId: string) { + toolId(runId); + const path = `/api/v1/flow-runs/${runId}`; + return { status_url: path, events_url: `${path}/events`, evidence_url: `${path}/evidence`, cancel_url: `${path}/cancel`, resume_url: `${path}/resume` }; +} +export function parseFlowToolRun(value: unknown, selected: FlowToolCatalogEntryV1, previous?: FlowToolRunV1): FlowToolRunV1 { + const run = wire(value, FLOW_TOOL_RUN_SCHEMA); + toolId(run.run_id); digest(run.input_digest); + if (run.tool_name !== selected.manifest.name || run.deployment_id !== selected.deployment_id + || run.manifest_digest !== selected.manifest.digest || run.flow_digest !== selected.manifest.flow.digest) throw new FlowToolError('invalid_contract'); + for (const [key, url] of Object.entries(flowToolRunLinks(run.run_id))) { + if (run[key as keyof FlowToolRunV1] !== url) throw new FlowToolError('invalid_contract'); + } + const terminal = ['completed', 'failed', 'cancelled'].includes(run.state); + if (terminal !== (run.terminal !== null)) throw new FlowToolError('invalid_contract'); + if (run.terminal !== null) { + const result = run.terminal; + const success = result.terminal_reason === 'success'; + if (success !== (run.state === 'completed') || (run.state === 'cancelled') !== (result.terminal_reason === 'canceled')) throw new FlowToolError('invalid_contract'); + if (success) { + if (result.business_verdict === null || !selected.business_verdicts.includes(result.business_verdict) || result.result === null) throw new FlowToolError('invalid_contract'); + try { validateFlowToolResult(selected.manifest, result.result); } catch { throw new FlowToolError('invalid_contract'); } + } else if (result.business_verdict !== null || result.result !== null) throw new FlowToolError('invalid_contract'); + const evidence = parseFlowToolEvidence(result.evidence, run.flow_digest); + const refs = new Set(evidence.artifacts.map(artifact => artifact.ref)); + const gateNames = new Set(); + for (const gate of result.gates) { + toolId(gate.name); toolId(gate.evidence_ref); + if (!refs.has(gate.evidence_ref) || gateNames.has(gate.name)) throw new FlowToolError('invalid_contract'); + gateNames.add(gate.name); + } + decimal(result.spend.dollars); + if (selected.budget.max_dollars !== null && result.spend.dollars_unmetered) throw new FlowToolError('invalid_contract'); + } + if (previous !== undefined) { + if (run.run_id !== previous.run_id || run.input_digest !== previous.input_digest || run.sequence < previous.sequence + || (run.sequence === previous.sequence && canonicalize(run) !== canonicalize(previous)) + || (previous.terminal !== null && canonicalize(run) !== canonicalize(previous))) throw new FlowToolError('invalid_contract'); + } + return run; +} +export function parseFlowToolEvent(value: unknown, run: FlowToolRunV1, cursor: number): FlowToolEventV1 { + const event = wire(value, FLOW_TOOL_EVENT_SCHEMA); + if (event.run_id !== run.run_id || event.flow_digest !== run.flow_digest || event.sequence <= cursor) throw new FlowToolError('invalid_contract'); + if (event.step_id !== undefined) toolId(event.step_id); + if (event.wait_id !== undefined) toolId(event.wait_id); + const terminal = ['completed', 'failed', 'cancelled'].includes(event.state); + if ((event.type === 'run.terminal') !== terminal || terminal !== (event.terminal_reason !== undefined) + || (event.type === 'run.accepted' && event.state !== 'accepted') + || (event.type !== 'human.required' && event.wait_id !== undefined) + || (event.type === 'human.required' && (event.wait_id === undefined || event.state !== 'parked')) + || (event.type === 'step.completed' && event.step_id === undefined) + || (terminal && ((event.state === 'completed') !== (event.terminal_reason === 'success') + || (event.state === 'cancelled') !== (event.terminal_reason === 'canceled')))) throw new FlowToolError('invalid_contract'); + return event; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3175046fa..5e22a2566 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -286,6 +286,13 @@ export { export { createFlow, type CreateFlowOptions, type CreatedFlow } from './create-flow.js'; +export * from './flow-tool-contract.js'; +export { parseFlowToolCatalog, canonicalFlowToolCatalog, parseFlowToolEntry, parseFlowToolRun, parseFlowToolEvent, + parseFlowToolEvidence, parseFlowToolInvocation, flowToolInputDigest, flowToolRunLinks } from './flow-tool-wire.js'; +export { FlowToolClient, type FlowToolTransport, type FlowToolRequest, type FlowToolInvocationOptions } from './flow-tool-client.js'; +export { createFlowToolHttpTransport, type FlowToolHttpOptions } from './flow-tool-http.js'; +export { createFlowToolAdapters } from './flow-tool-adapters.js'; + export { renderProgress, type ProgressEvent } from './progress.js'; export { webhookTriggerSpec } from './trigger-executor.js'; export { scheduleTriggerSpec, scheduleLowering, SCHEDULE_EXECUTOR, type ScheduleLowering } from './schedule-trigger.js'; diff --git a/packages/sdk/tests/fixtures/flow-tool-api-v1.json b/packages/sdk/tests/fixtures/flow-tool-api-v1.json new file mode 100644 index 000000000..fd5ab4270 --- /dev/null +++ b/packages/sdk/tests/fixtures/flow-tool-api-v1.json @@ -0,0 +1,177 @@ +{ + "fixture_only": true, + "catalog": { + "api_version": 1, + "tools": [ + { + "manifest": { + "manifestVersion": 1, + "schemaDialect": "https://json-schema.org/draft/2020-12/schema", + "name": "review_pr", + "description": "Read-only review; a hold is not approval.", + "flow": { + "name": "review-pr", + "version": "1.0.0", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "inputSchema": { + "type": "object", + "properties": { + "pr": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "pr" + ], + "additionalProperties": false + }, + "resultSchema": { + "type": "object", + "properties": { + "findings": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "findings" + ], + "additionalProperties": false + }, + "digest": "sha256:24802f6cbe59f6534d1e60bd6cd5eaad9bff5334d8dd36c117a778625744dabf" + }, + "deployment_id": "deployment_1", + "read_only": true, + "effects": [ + "github:read" + ], + "requires_human": [], + "business_verdicts": [ + "pass", + "hold" + ], + "budget": { + "max_tokens": 1000, + "max_dollars": "1.00", + "max_wallclock_ms": 10000 + } + } + ] + }, + "invoke": { + "api_version": 1, + "flow": "review-pr@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "deployment_id": "deployment_1", + "manifest_digest": "sha256:24802f6cbe59f6534d1e60bd6cd5eaad9bff5334d8dd36c117a778625744dabf", + "input": { + "pr": 42 + }, + "input_digest": "sha256:c4bbf2df03603acfb40a2f3172e590cfd2e8cfb443cbfd0f841f08f625ae7e46", + "mode": "async", + "wait_ms": 0 + }, + "receipt": { + "api_version": 1, + "accepted": true, + "run_id": "run_fixture", + "tool_name": "review_pr", + "deployment_id": "deployment_1", + "manifest_digest": "sha256:24802f6cbe59f6534d1e60bd6cd5eaad9bff5334d8dd36c117a778625744dabf", + "flow_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "input_digest": "sha256:c4bbf2df03603acfb40a2f3172e590cfd2e8cfb443cbfd0f841f08f625ae7e46", + "state": "accepted", + "sequence": 1, + "terminal": null, + "status_url": "/api/v1/flow-runs/run_fixture", + "events_url": "/api/v1/flow-runs/run_fixture/events", + "evidence_url": "/api/v1/flow-runs/run_fixture/evidence", + "cancel_url": "/api/v1/flow-runs/run_fixture/cancel", + "resume_url": "/api/v1/flow-runs/run_fixture/resume" + }, + "terminal": { + "api_version": 1, + "accepted": true, + "run_id": "run_fixture", + "tool_name": "review_pr", + "deployment_id": "deployment_1", + "manifest_digest": "sha256:24802f6cbe59f6534d1e60bd6cd5eaad9bff5334d8dd36c117a778625744dabf", + "flow_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "input_digest": "sha256:c4bbf2df03603acfb40a2f3172e590cfd2e8cfb443cbfd0f841f08f625ae7e46", + "state": "completed", + "sequence": 2, + "terminal": { + "terminal_reason": "success", + "business_verdict": "hold", + "result": { + "findings": 1 + }, + "gates": [ + { + "name": "review", + "status": "fail", + "evidence_ref": "artifact_1" + } + ], + "evidence": { + "journal_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "flow_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": [ + { + "name": "review", + "ref": "artifact_1", + "media_type": "application/json" + } + ], + "redacted_transcript_refs": [] + }, + "spend": { + "tokens_in": 20, + "tokens_out": 5, + "dollars": "0.01", + "dollars_unmetered": false + } + }, + "status_url": "/api/v1/flow-runs/run_fixture", + "events_url": "/api/v1/flow-runs/run_fixture/events", + "evidence_url": "/api/v1/flow-runs/run_fixture/evidence", + "cancel_url": "/api/v1/flow-runs/run_fixture/cancel", + "resume_url": "/api/v1/flow-runs/run_fixture/resume" + }, + "evidence": { + "api_version": 1, + "run_id": "run_fixture", + "evidence": { + "journal_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "flow_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": [ + { + "name": "review", + "ref": "artifact_1", + "media_type": "application/json" + } + ], + "redacted_transcript_refs": [] + } + }, + "events": [ + { + "api_version": 1, + "run_id": "run_fixture", + "flow_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sequence": 1, + "type": "run.accepted", + "state": "accepted" + }, + { + "api_version": 1, + "run_id": "run_fixture", + "flow_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sequence": 2, + "type": "run.terminal", + "state": "completed", + "terminal_reason": "success" + } + ] +} diff --git a/packages/sdk/tests/flow-tool-control-fixture.ts b/packages/sdk/tests/flow-tool-control-fixture.ts new file mode 100644 index 000000000..7b662254d --- /dev/null +++ b/packages/sdk/tests/flow-tool-control-fixture.ts @@ -0,0 +1,105 @@ +// Persisted CONFORMANCE FIXTURE, not Cloud admission or a production durable ledger. +// Synchronous file operations serialize this one-process fixture only. Production +// must use transactional scoped uniqueness + durable launch reconciliation. +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createFlowToolManifest } from '../src/flow-tool-manifest.js'; +import { FlowToolError, type FlowToolCatalogEntryV1, type FlowToolRunV1, type FlowToolEventV1 } from '../src/flow-tool-contract.js'; +import { flowToolRunLinks, parseFlowToolInvocation } from '../src/flow-tool-wire.js'; +import type { FlowToolRequest, FlowToolTransport } from '../src/flow-tool-client.js'; +import { sha256 } from '../src/bundle.js'; +import { canonicalize } from '../src/canonical.js'; + +export const entry: FlowToolCatalogEntryV1 = { + manifest: createFlowToolManifest({ + name: 'review_pr', description: 'Read-only review; a hold is not approval.', + flow: { name: 'review-pr', version: '1.0.0', digest: `sha256:${'a'.repeat(64)}` }, + inputSchema: { type: 'object', properties: { pr: { type: 'integer', minimum: 1 } }, required: ['pr'], additionalProperties: false }, + resultSchema: { type: 'object', properties: { findings: { type: 'integer', minimum: 0 } }, required: ['findings'], additionalProperties: false }, + }), + deployment_id: 'deployment_1', read_only: true, effects: ['github:read'], requires_human: [], + business_verdicts: ['pass', 'hold'], budget: { max_tokens: 1000, max_dollars: '1.00', max_wallclock_ms: 10000 }, +}; +export const copy = (value: T): T => JSON.parse(JSON.stringify(value)); +interface Stored { + principal: string; + run: FlowToolRunV1; + events: FlowToolEventV1[]; + commands: Record; +} +type Ledger = Record; +export class FixtureControlPlane implements FlowToolTransport { + calls: FlowToolRequest[] = []; + loseNextAdmissionResponse = false; + constructor(readonly path: string, readonly principal = 'tenant-1/principal-1', readonly authorized = true) {} + private load(): Ledger { return existsSync(this.path) ? JSON.parse(readFileSync(this.path, 'utf8')) : {}; } + private save(ledger: Ledger) { writeFileSync(this.path, JSON.stringify(ledger)); } + get count(): number { return Object.keys(this.load()).length; } + private lookup(ledger: Ledger, runId: string) { + const stored = Object.values(ledger).find(value => value.run.run_id === runId && value.principal === this.principal); + if (!this.authorized || stored === undefined) throw new FlowToolError('not_authorized'); + return stored; + } + async request(request: FlowToolRequest): Promise { + this.calls.push(copy(request)); + if (request.path === '/api/v1/flow-tools') return { api_version: 1, tools: this.authorized ? [entry] : [] }; + if (!this.authorized) throw new FlowToolError('not_authorized'); + const ledger = this.load(); + if (request.path === '/api/v1/flow-tools/review_pr/invoke') { + const body = parseFlowToolInvocation(request.body, entry); + const key = sha256(canonicalize([this.principal, entry.deployment_id, entry.manifest.flow.digest, request.idempotencyKey])); + const old = ledger[key]; + if (old) { + if (old.run.input_digest !== body.input_digest) throw new FlowToolError('idempotency_conflict'); + return copy(old.run); + } + const runId = `run_${key.slice(0, 32)}`; + const run: FlowToolRunV1 = { + api_version: 1, accepted: true, run_id: runId, tool_name: entry.manifest.name, + deployment_id: entry.deployment_id, manifest_digest: entry.manifest.digest, + flow_digest: entry.manifest.flow.digest, input_digest: body.input_digest, + state: 'accepted', sequence: 1, terminal: null, ...flowToolRunLinks(runId), + }; + ledger[key] = { principal: this.principal, run, commands: {}, events: [{ + api_version: 1, run_id: runId, flow_digest: run.flow_digest, sequence: 1, type: 'run.accepted', state: 'accepted', + }] }; + this.save(ledger); + if (this.loseNextAdmissionResponse) { this.loseNextAdmissionResponse = false; throw new FlowToolError('transport_error'); } + return copy(run); + } + const match = request.path.match(/^\/api\/v1\/flow-runs\/([^/]+)(.*)$/)!; + const stored = this.lookup(ledger, match[1]!); + if (match[2] === '/evidence') return { api_version: 1, run_id: stored.run.run_id, evidence: stored.run.terminal?.evidence }; + if (request.method === 'POST') { + const signature = canonicalize([match[2], request.body]); + const old = stored.commands[request.idempotencyKey!]; + if (old !== undefined && old !== signature) throw new FlowToolError('idempotency_conflict'); + stored.commands[request.idempotencyKey!] = signature; + if (match[2] === '/cancel' && stored.run.terminal === null) this.finish(stored, 'canceled'); + this.save(ledger); + } + return copy(stored.run); + } + complete(runId: string) { + const ledger = this.load(); + this.finish(this.lookup(ledger, runId), 'success'); + this.save(ledger); + } + private finish(stored: Stored, reason: 'success' | 'canceled') { + const run = stored.run; + stored.run = { ...run, sequence: run.sequence + 1, state: reason === 'success' ? 'completed' : 'cancelled', terminal: { + terminal_reason: reason, business_verdict: reason === 'success' ? 'hold' : null, + result: reason === 'success' ? { findings: 1 } : null, + gates: [{ name: 'review', status: 'fail', evidence_ref: 'artifact_1' }], + evidence: { journal_digest: `sha256:${'b'.repeat(64)}`, flow_digest: run.flow_digest, + artifacts: [{ name: 'review', ref: 'artifact_1', media_type: 'application/json' }], redacted_transcript_refs: [] }, + spend: { tokens_in: 20, tokens_out: 5, dollars: '0.01', dollars_unmetered: false }, + } }; + stored.events.push({ api_version: 1, run_id: run.run_id, flow_digest: run.flow_digest, + sequence: stored.run.sequence, type: 'run.terminal', state: stored.run.state, terminal_reason: reason }); + } + async *events(path: string, after: number) { + const runId = path.split('/')[4]!; + const stored = this.lookup(this.load(), runId); + for (const event of stored.events) if (event.sequence > after) yield copy(event); + } +} diff --git a/packages/sdk/tests/flow-tool-control.test.ts b/packages/sdk/tests/flow-tool-control.test.ts new file mode 100644 index 000000000..43dc5311b --- /dev/null +++ b/packages/sdk/tests/flow-tool-control.test.ts @@ -0,0 +1,219 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ToolSchema, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { FlowToolClient, type FlowToolTransport } from '../src/flow-tool-client.js'; +import { createFlowToolAdapters } from '../src/flow-tool-adapters.js'; +import { parseFlowToolCatalog, canonicalFlowToolCatalog, parseFlowToolInvocation, parseFlowToolRun, parseFlowToolEvent } from '../src/flow-tool-wire.js'; +import { createFlowToolManifest } from '../src/flow-tool-manifest.js'; +import { FixtureControlPlane, entry, copy } from './flow-tool-control-fixture.js'; + +const directories: string[] = []; +afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'flow-tool-contract-')); + directories.push(directory); + const backend = new FixtureControlPlane(join(directory, 'fixture.json')); + return { backend, client: new FlowToolClient(backend) }; +} +const invoke = (client: FlowToolClient, key = 'operation-1') => client.invoke(entry, { pr: 42 }, { idempotencyKey: key }); + +describe('canonical Flow Tool lifecycle (persisted fixture, not hosted execution)', () => { + it('discovers a pinned read-only revision, admits asynchronously, and validates a hold result/evidence', async () => { + const { client, backend } = fixture(); + expect((await client.discover()).tools).toEqual([entry]); + const receipt = await invoke(client); + expect(receipt.state).toBe('accepted'); expect(receipt.terminal).toBeNull(); + backend.complete(receipt.run_id); + const result = await client.status(entry, receipt); + expect(result.terminal?.terminal_reason).toBe('success'); + expect(result.terminal?.business_verdict).toBe('hold'); + expect(result.terminal?.gates[0]?.status).toBe('fail'); + expect(await client.evidence(entry, result)).toEqual(result.terminal?.evidence); + }); + + it('preserves one admission across concurrent fixture requests, lost response and reconstruction', async () => { + const { client, backend } = fixture(); + backend.loseNextAdmissionResponse = true; + await expect(invoke(client)).rejects.toMatchObject({ code: 'transport_error' }); + const reconstructed = new FlowToolClient(new FixtureControlPlane(backend.path)); + const receipts = await Promise.all(Array.from({ length: 8 }, () => invoke(reconstructed))); + expect(new Set(receipts.map(receipt => receipt.run_id)).size).toBe(1); + expect(backend.count).toBe(1); + await expect(reconstructed.invoke(entry, { pr: 43 }, { idempotencyKey: 'operation-1' })).rejects.toMatchObject({ code: 'idempotency_conflict' }); + expect(backend.count).toBe(1); + }); + + it('uses persisted completion after reconstruction and never resumes a new invocation', async () => { + const { client, backend } = fixture(); + const receipt = await invoke(client); backend.complete(receipt.run_id); + const restored = new FlowToolClient(new FixtureControlPlane(backend.path)); + const terminal = await restored.status(entry, receipt); + expect(await restored.resume(entry, terminal, 'resume-1')).toEqual(terminal); + expect(await invoke(restored)).toEqual(terminal); + expect(backend.count).toBe(1); + }); + + it('replays strictly after a persisted cursor and reports the same digest', async () => { + const { client, backend } = fixture(); + const receipt = await invoke(client); + const first = []; for await (const event of client.events(entry, receipt)) first.push(event); + expect(first.map(event => event.sequence)).toEqual([1]); + backend.complete(receipt.run_id); + const restored = new FlowToolClient(new FixtureControlPlane(backend.path)); + const next = []; for await (const event of restored.events(entry, receipt, 1)) next.push(event); + expect(next.map(event => event.sequence)).toEqual([2]); + expect(next[0]?.flow_digest).toBe(entry.manifest.flow.digest); + }); + + it('scopes fixture idempotency by principal and refuses cross-principal status/evidence/events', async () => { + const { client, backend } = fixture(), receipt = await invoke(client); + const other = new FlowToolClient(new FixtureControlPlane(backend.path, 'tenant-2/principal-2')); + expect((await invoke(other)).run_id).not.toBe(receipt.run_id); + await expect(other.status(entry, receipt)).rejects.toMatchObject({ code: 'not_authorized' }); + await expect(other.evidence(entry, receipt)).rejects.toMatchObject({ code: 'not_authorized' }); + await expect(other.events(entry, receipt)[Symbol.asyncIterator]().next()).rejects.toMatchObject({ code: 'not_authorized' }); + const denied = new FlowToolClient(new FixtureControlPlane(backend.path, 'tenant-1/principal-1', false)); + expect((await denied.discover()).tools).toEqual([]); + await expect(invoke(denied)).rejects.toMatchObject({ code: 'not_authorized' }); + }); + + it('keeps cancel idempotent and derives human identity outside the payload', async () => { + const { client, backend } = fixture(), receipt = await invoke(client); + await client.answer(entry, receipt, 'wait-1', false, 'answer-1'); + expect(backend.calls.at(-1)?.body).toEqual({ api_version: 1, input: { approved: false } }); + const cancelled = await client.cancel(entry, receipt, 'cancel-1'); + expect(cancelled.terminal?.terminal_reason).toBe('canceled'); + expect(await client.cancel(entry, cancelled, 'cancel-1')).toEqual(cancelled); + expect(await client.resume(entry, cancelled, 'resume-1')).toEqual(cancelled); + await expect(client.answer(entry, cancelled, 'wait-1', true, 'answer-1')).rejects.toMatchObject({ code: 'idempotency_conflict' }); + expect(backend.count).toBe(1); + }); + + it('native, MCP and action adapters produce the identical canonical receipt with one operation key', async () => { + const { client, backend } = fixture(), adapters = createFlowToolAdapters(client, entry); + const operation = { idempotencyKey: 'adapter-operation' }; + const native = await adapters.native.call({ pr: 42 }, operation); + const mcp = await adapters.mcp.call({ pr: 42 }, operation); + const action = await adapters.action.invoke({ pr: 42 }, operation); + expect(native).toEqual(mcp.structuredContent); expect(native).toEqual(action); + expect(JSON.parse(mcp.content[0]!.text)).toEqual(native); + expect(ToolSchema.parse(adapters.mcp.definition)).toEqual(adapters.mcp.definition); + expect(CallToolResultSchema.parse(mcp)).toEqual(mcp); + expect(adapters.mcp.definition.outputSchema).not.toEqual(entry.manifest.resultSchema); + expect(backend.count).toBe(1); + }); + + it.each([{}, null, { pr: '42' }, { pr: 42, tenant: 'escape' }, { pr: 42, budget: 999 }, { pr: 42, text: 'x'.repeat(262145) }])('rejects invalid input before transport %#', async input => { + const { client, backend } = fixture(); + await expect(client.invoke(entry, input, { idempotencyKey: 'operation' })).rejects.toThrow(); + expect(backend.calls).toHaveLength(0); expect(backend.count).toBe(0); + }); + + it.each([{ mode: 'async', waitMs: 1 }, { mode: 'sync', waitMs: 25001 }, { mode: 'sync', waitMs: -1 }, { mode: 'other' }])('refuses invalid observation options %#', async options => { + const { client, backend } = fixture(); + await expect(client.invoke(entry, { pr: 42 }, { idempotencyKey: 'op', ...options } as never)).rejects.toThrow(); + expect(backend.calls).toHaveLength(0); + }); + + it('bounded sync uses the same admission path and may return an unfinished receipt', async () => { + const { client, backend } = fixture(); + const run = await client.invoke(entry, { pr: 42 }, { idempotencyKey: 'op', mode: 'sync', waitMs: 25000 }); + expect(run.terminal).toBeNull(); + expect(backend.calls[0]?.body).toMatchObject({ mode: 'sync', wait_ms: 25000 }); + }); +}); + +describe('fail-closed public projections', () => { + it('validates the shared Cloud/SDK golden wire fixture without recomputing its stored hashes', () => { + const fixture = JSON.parse(readFileSync(new URL('./fixtures/flow-tool-api-v1.json', import.meta.url), 'utf8')); + expect(fixture.fixture_only).toBe(true); + const selected = parseFlowToolCatalog(fixture.catalog).tools[0]!; + parseFlowToolInvocation(fixture.invoke, selected); + const receipt = parseFlowToolRun(fixture.receipt, selected); + expect(parseFlowToolRun(fixture.terminal, selected, receipt).terminal?.business_verdict).toBe('hold'); + let cursor = 0; + for (const event of fixture.events) cursor = parseFlowToolEvent(event, receipt, cursor).sequence; + expect(cursor).toBe(2); + }); + it('canonicalizes discovery independently of catalog order', () => { + const second = { ...entry, manifest: createFlowToolManifest({ + name: 'another_tool', description: entry.manifest.description, flow: entry.manifest.flow, + inputSchema: entry.manifest.inputSchema, resultSchema: entry.manifest.resultSchema, + }) }; + expect(canonicalFlowToolCatalog({ api_version: 1, tools: [entry, second] })) + .toBe(canonicalFlowToolCatalog({ tools: [second, entry], api_version: 1 })); + }); + + it('rejects admission receipt bound to a different canonical input', async () => { + const { client } = fixture(), receipt = await invoke(client); + const transport: FlowToolTransport = { + request: async () => ({ ...receipt, input_digest: `sha256:${'c'.repeat(64)}` }), + async *events() { /* not used */ }, + }; + await expect(invoke(new FlowToolClient(transport))).rejects.toMatchObject({ code: 'invalid_contract' }); + }); + + it.each(['gate_failed', 'model_failed', 'agent_failed', 'budget_exceeded', 'human_rejected', 'human_timeout', 'execution_failed'])('preserves distinct failed terminal reason %s', async reason => { + const { client, backend } = fixture(), receipt = await invoke(client); backend.complete(receipt.run_id); + const raw = copy(await client.status(entry, receipt)) as any; + raw.state = 'failed'; raw.terminal.terminal_reason = reason; + raw.terminal.business_verdict = null; raw.terminal.result = null; + expect(parseFlowToolRun(raw, entry, receipt).terminal?.terminal_reason).toBe(reason); + }); + it.each([ + (value: any) => { value.tools[0].read_only = false; }, + (value: any) => { value.tools[0].effects = ['github:write']; }, + (value: any) => { value.tools.push(copy(value.tools[0])); }, + (value: any) => { value.tools[0].manifest.description = 'input-injected description'; }, + (value: any) => { value.tools[0].budget.max_dollars = '-1'; }, + (value: any) => { value.tools[0].credentials = 'secret-fixture'; }, + (value: any) => { value.api_version = 2; }, + ])('refuses malformed/escalated discovery %# without echoing data', change => { + const value = copy({ api_version: 1, tools: [entry] }); change(value); + expect(() => parseFlowToolCatalog(value)).toThrow('invalid_contract'); + try { parseFlowToolCatalog(value); } catch (error) { expect(String(error)).not.toContain('secret-fixture'); } + }); + + it.each([ + (value: any) => { value.terminal = null; }, + (value: any) => { value.state = 'running'; }, + (value: any) => { value.flow_digest = `sha256:${'c'.repeat(64)}`; }, + (value: any) => { value.manifest_digest = `sha256:${'c'.repeat(64)}`; }, + (value: any) => { value.status_url = 'https://attacker.invalid/run'; }, + (value: any) => { value.terminal.result.findings = 'one'; }, + (value: any) => { value.terminal.business_verdict = 'merge'; }, + (value: any) => { value.terminal.terminal_reason = 'model_sentence_success'; }, + (value: any) => { value.terminal.spend.dollars_unmetered = true; }, + (value: any) => { value.terminal.spend.tokens_in = -1; }, + (value: any) => { value.terminal.gates[0].evidence_ref = 'missing'; }, + (value: any) => { value.terminal.evidence.artifacts[0].ref = 'https://attacker.invalid/secret'; }, + (value: any) => { value.terminal.evidence.environment = { TOKEN: 'secret-fixture' }; }, + (value: any) => { value.terminal.evidence.journal_digest = 'mutable'; }, + (value: any) => { value.sequence = 0; }, + ])('refuses contradictory, unbound or unsafe terminal data %#', async change => { + const { client, backend } = fixture(), receipt = await invoke(client); backend.complete(receipt.run_id); + const raw = copy(await client.status(entry, receipt)); change(raw); + expect(() => parseFlowToolRun(raw, entry, receipt)).toThrow('invalid_contract'); + }); + + it.each(['flow', 'deployment_id', 'manifest_digest', 'input_digest', 'tenant_id'])('refuses forged admission binding %s', async field => { + const { client, backend } = fixture(); await invoke(client); + const body = copy(backend.calls[0]!.body) as any; body[field] = 'forged'; + expect(() => parseFlowToolInvocation(body, entry)).toThrow('invalid_contract'); + }); + + it('rejects changed terminal receipts, replayed event IDs and raw protected event payloads', async () => { + const { client, backend } = fixture(), receipt = await invoke(client); backend.complete(receipt.run_id); + const terminal = await client.status(entry, receipt), changed = copy(terminal) as any; + changed.terminal.result.findings = 2; changed.sequence++; + expect(() => parseFlowToolRun(changed, entry, terminal)).toThrow('invalid_contract'); + const event = { api_version: 1, run_id: receipt.run_id, flow_digest: receipt.flow_digest, sequence: 1, state: 'accepted', type: 'run.accepted' }; + expect(() => parseFlowToolEvent(event, receipt, 1)).toThrow('invalid_contract'); + expect(() => parseFlowToolEvent({ ...event, prompt: 'secret-fixture' }, receipt, 0)).toThrow('invalid_contract'); + const bad: FlowToolTransport = { request: async () => receipt, async *events() { yield event; yield event; } }; + const iterator = new FlowToolClient(bad).events(entry, receipt)[Symbol.asyncIterator](); + await iterator.next(); await expect(iterator.next()).rejects.toThrow('invalid_contract'); + }); +}); diff --git a/packages/sdk/tests/flow-tool-http.test.ts b/packages/sdk/tests/flow-tool-http.test.ts new file mode 100644 index 000000000..7ddda52ee --- /dev/null +++ b/packages/sdk/tests/flow-tool-http.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createFlowToolHttpTransport } from '../src/flow-tool-http.js'; +import { FlowToolClient } from '../src/flow-tool-client.js'; +import { flowToolInputDigest, flowToolRunLinks } from '../src/flow-tool-wire.js'; +import { entry } from './flow-tool-control-fixture.js'; +import type { FlowToolRunV1 } from '../src/flow-tool-contract.js'; + +const receipt: FlowToolRunV1 = { + api_version: 1, accepted: true, run_id: 'run_fixture', tool_name: entry.manifest.name, + deployment_id: entry.deployment_id, manifest_digest: entry.manifest.digest, flow_digest: entry.manifest.flow.digest, + input_digest: flowToolInputDigest({ pr: 42 }), state: 'accepted', sequence: 1, terminal: null, ...flowToolRunLinks('run_fixture'), +}; +const event = { api_version: 1, run_id: receipt.run_id, flow_digest: receipt.flow_digest, sequence: 2, type: 'run.state_changed', state: 'running' }; +function sse(body: string) { return new Response(body, { headers: { 'content-type': 'text/event-stream' } }); } +function transport(fetcher: typeof fetch) { + return createFlowToolHttpTransport({ apiUrl: 'https://example.invalid/cloud', token: 'test-only-scoped-bearer', fetch: fetcher }); +} +function frame(value = event) { return `id: ${value.sequence}\nevent: ${value.type}\ndata: ${JSON.stringify(value)}\n\n`; } + +describe('explicit authenticated Flow Tool HTTP transport (fetch fixtures)', () => { + it('sends pinned canonical input, dedicated header identity and stable idempotency, never follows redirects', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(receipt), { headers: { 'content-type': 'application/json; charset=utf-8' } })); + expect(await new FlowToolClient(transport(fetcher)).invoke(entry, { pr: 42 }, { idempotencyKey: 'operation-1' })).toEqual(receipt); + const [url, init] = fetcher.mock.calls[0]!; + expect(url).toBe('https://example.invalid/cloud/api/v1/flow-tools/review_pr/invoke'); + expect(init?.redirect).toBe('error'); + expect(init?.headers).toMatchObject({ Authorization: 'Bearer test-only-scoped-bearer', 'Idempotency-Key': 'operation-1' }); + expect(String(init?.body)).not.toContain('bearer'); + expect(JSON.parse(String(init?.body))).toMatchObject({ flow: `review-pr@${receipt.flow_digest}`, manifest_digest: receipt.manifest_digest, input_digest: receipt.input_digest }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it.each([[400, 'invalid_contract'], [401, 'not_authorized'], [403, 'not_authorized'], [404, 'not_found'], [409, 'idempotency_conflict'], [422, 'invalid_contract'], [501, 'unsupported'], [503, 'unavailable']])('maps HTTP %s without echoing bodies or retrying', async (status, code) => { + const fetcher = vi.fn().mockImplementation(async () => new Response('secret-response-fixture', { status: Number(status) })); + await expect(new FlowToolClient(transport(fetcher)).discover()).rejects.toMatchObject({ code }); + try { await new FlowToolClient(transport(fetcher)).discover(); } catch (error) { expect(String(error)).not.toContain('secret-response-fixture'); } + expect(fetcher).toHaveBeenCalledTimes(2); // two explicit calls, zero implicit retries + }); + + it('treats disconnect as unknown outcome, never a cancellation or automatic retry', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('secret-token-fixture')); + await expect(new FlowToolClient(transport(fetcher)).invoke(entry, { pr: 42 }, { idempotencyKey: 'op' })).rejects.toThrow('admission or command outcome may be unknown'); + expect(fetcher).toHaveBeenCalledTimes(1); + try { await new FlowToolClient(transport(fetcher)).discover(); } catch (error) { expect(String(error)).not.toContain('secret-token-fixture'); } + }); + + it.each([ + { apiUrl: 'http://example.invalid', token: 'scoped' }, + { apiUrl: 'https://example.invalid?token=secret', token: 'scoped' }, + { apiUrl: 'https://example.invalid', token: 'rk_live_secret' }, + { apiUrl: 'https://example.invalid', token: 'ot_live_secret' }, + { apiUrl: 'https://example.invalid', token: 'injected\r\nHeader: value' }, + ])('refuses unsafe endpoint/credential configuration %# without request', options => { + const fetcher = vi.fn(); + expect(() => createFlowToolHttpTransport({ ...options, fetch: fetcher })).toThrow(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it.each(['https://attacker.invalid', '/api/v1/flow-runs/../secret', '/api/v1/flow-runs/run_1?token=secret'])('refuses unsafe path %s before fetch', async path => { + const fetcher = vi.fn(); + await expect(transport(fetcher).request({ method: 'GET', path })).rejects.toMatchObject({ code: 'invalid_contract' }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it.each([ + ['text/html', '{}'], ['application/json', '{malformed'], ['application/json', 'x'.repeat(262145)], + ])('refuses invalid or oversized JSON response %#', async (contentType, body) => { + const fetcher = vi.fn().mockImplementation(async () => new Response(body, { headers: { 'content-type': contentType } })); + await expect(new FlowToolClient(transport(fetcher)).discover()).rejects.toMatchObject({ code: 'invalid_contract' }); + }); + + it('reconnects SSE using journal cursor; parses split UTF-8/CRLF frames and ignores heartbeat comments', async () => { + const bytes = new TextEncoder().encode(`: heartbeat\r\n\r\n${frame().replaceAll('\n', '\r\n')}`); + const fetcher = vi.fn().mockImplementation(async () => new Response(new ReadableStream({ + start(controller) { for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); controller.close(); }, + }), { headers: { 'content-type': 'text/event-stream' } })); + const values = []; for await (const value of new FlowToolClient(transport(fetcher)).events(entry, receipt, 1)) values.push(value); + expect(values).toEqual([event]); + expect(fetcher.mock.calls[0]?.[1]?.headers).toMatchObject({ 'Last-Event-ID': '1' }); + }); + + it.each([ + frame().replace('id: 2', 'id: 1'), + frame().replace('id: 2', 'id: NaN'), + frame().replace('event: run.state_changed', 'event: another'), + frame().slice(0, -1), + `id: 2\nevent: run.state_changed\ndata: ${'x'.repeat(65537)}\n\n`, + frame() + frame(), + ])('rejects mismatched, incomplete, oversized or repeated event frames %#', async body => { + const fetcher = vi.fn().mockImplementation(async () => sse(body)); + const consume = async () => { for await (const _ of new FlowToolClient(transport(fetcher)).events(entry, receipt, 1)) { /* validate every event */ } }; + await expect(consume()).rejects.toMatchObject({ code: 'invalid_contract' }); + }); +}); diff --git a/packages/sdk/tests/flow-tool-public-api.test.ts b/packages/sdk/tests/flow-tool-public-api.test.ts index f5b294d71..37b662f3a 100644 --- a/packages/sdk/tests/flow-tool-public-api.test.ts +++ b/packages/sdk/tests/flow-tool-public-api.test.ts @@ -11,6 +11,7 @@ it('round-trips the manifest contract through the built public SDK without execu createFlowToolManifest, canonicalFlowToolManifest, parseFlowToolManifest, validateFlowToolInput, validateFlowToolResult, flowToolFunctionDefinition, flowToolMcpDefinition, + FlowToolClient, createFlowToolAdapters, flowToolRunLinks, flowToolInputDigest, } from ${JSON.stringify(sdkUrl)}; const manifest = createFlowToolManifest({ @@ -54,6 +55,25 @@ it('round-trips the manifest contract through the built public SDK without execu }); assert.equal('annotations' in mcp, false); assert.equal('strict' in native, false); + // A transport fixture exercises public client exports, not Cloud or a real run. + const selected = { + manifest: restored, deployment_id: 'test_deployment', read_only: true, + effects: [], requires_human: [], business_verdicts: ['hold', 'pass'], + budget: { max_tokens: 100, max_dollars: null, max_wallclock_ms: 1000 }, + }; + const receipt = { + api_version: 1, accepted: true, run_id: 'run_fixture', tool_name: restored.name, + deployment_id: selected.deployment_id, manifest_digest: restored.digest, + flow_digest: restored.flow.digest, input_digest: flowToolInputDigest({ pr: 42 }), + state: 'accepted', sequence: 1, terminal: null, ...flowToolRunLinks('run_fixture'), + }; + const client = new FlowToolClient({ request: async () => receipt, async *events() {} }); + const handlers = createFlowToolAdapters(client, selected); + const operation = { idempotencyKey: 'test-operation' }; + const nativeRun = await handlers.native.call({ pr: 42 }, operation); + const mcpRun = await handlers.mcp.call({ pr: 42 }, operation); + assert.equal(JSON.stringify(nativeRun), JSON.stringify(mcpRun.structuredContent)); + assert.equal(nativeRun.terminal, null); console.log('FLOW_TOOL_PUBLIC_CONTRACT_OK'); `], { encoding: 'utf8', timeout: 15_000 }); expect(output.trim()).toBe('FLOW_TOOL_PUBLIC_CONTRACT_OK'); From 225462ab6ef1dd1db17d0ab4f80b18cb0ffd8daa Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 24 Sep 2026 07:17:07 +0200 Subject: [PATCH 4/6] fix(sdk): redact callable boundary validation failures --- packages/sdk/src/flow-tool-client.ts | 9 +++++++-- packages/sdk/tests/flow-tool-control.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/flow-tool-client.ts b/packages/sdk/src/flow-tool-client.ts index 5a7872852..b097927bb 100644 --- a/packages/sdk/src/flow-tool-client.ts +++ b/packages/sdk/src/flow-tool-client.ts @@ -41,7 +41,9 @@ export class FlowToolClient { async invoke(selected: FlowToolCatalogEntryV1, input: unknown, options: FlowToolInvocationOptions): Promise { const entry = parseFlowToolEntry(selected); // Schema validation precedes ALL transport calls, including credential access. - const validated = validateFlowToolInput(entry.manifest, input); + let validated: ReturnType; + try { validated = validateFlowToolInput(entry.manifest, input); } + catch { throw new FlowToolError('invalid_contract'); } flowToolOperationKey(options.idempotencyKey); const mode = options.mode ?? 'async'; const waitMs = options.waitMs ?? 0; @@ -79,7 +81,10 @@ export class FlowToolClient { async evidence(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1) { const run = parseFlowToolRun(receipt, parseFlowToolEntry(selected)); - const raw = snapshotJsonValue(await this.transport.request({ method: 'GET', path: run.evidence_url }), 'flow tool evidence', FLOW_TOOL_LIMITS); + const response = await this.transport.request({ method: 'GET', path: run.evidence_url }); + let raw: ReturnType; + try { raw = snapshotJsonValue(response, 'flow tool evidence', FLOW_TOOL_LIMITS); } + catch { throw new FlowToolError('invalid_contract'); } if (raw === null || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).sort().join(',') !== 'api_version,evidence,run_id' || raw.api_version !== 1 || raw.run_id !== run.run_id) throw new FlowToolError('invalid_contract'); diff --git a/packages/sdk/tests/flow-tool-control.test.ts b/packages/sdk/tests/flow-tool-control.test.ts index 43dc5311b..5c09c4191 100644 --- a/packages/sdk/tests/flow-tool-control.test.ts +++ b/packages/sdk/tests/flow-tool-control.test.ts @@ -155,6 +155,20 @@ describe('fail-closed public projections', () => { await expect(invoke(new FlowToolClient(transport))).rejects.toMatchObject({ code: 'invalid_contract' }); }); + it('does not echo sensitive property names from snapshot failures at the client boundary', async () => { + const { client } = fixture(), receipt = await invoke(client); + const invalid = client.invoke(entry, { 'secret-key-fixture': NaN }, { idempotencyKey: 'op' }); + await expect(invalid).rejects.toMatchObject({ code: 'invalid_contract' }); + try { await invalid; } + catch (error) { expect(String(error)).not.toContain('secret-key-fixture'); expect(error).toMatchObject({ code: 'invalid_contract' }); } + const transport: FlowToolTransport = { + request: async () => ({ 'secret-key-fixture': Infinity }), async *events() {}, + }; + await expect(new FlowToolClient(transport).evidence(entry, receipt)).rejects.toMatchObject({ code: 'invalid_contract' }); + try { await new FlowToolClient(transport).evidence(entry, receipt); } + catch (error) { expect(String(error)).not.toContain('secret-key-fixture'); } + }); + it.each(['gate_failed', 'model_failed', 'agent_failed', 'budget_exceeded', 'human_rejected', 'human_timeout', 'execution_failed'])('preserves distinct failed terminal reason %s', async reason => { const { client, backend } = fixture(), receipt = await invoke(client); backend.complete(receipt.run_id); const raw = copy(await client.status(entry, receipt)) as any; From 472f5b7e61403b03ce619418bc65e242bc702c84 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 14:02:51 -0700 Subject: [PATCH 5/6] feat(sdk): execute constrained flow tools through kernel Session-Id: 01a0d525-feb9-77e3-9f0c-a5fcb22f7d79 --- packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md | 42 ++- packages/sdk/FLOW-TOOLS.md | 20 +- packages/sdk/src/flow-tool-kernel.ts | 310 ++++++++++++++++++ packages/sdk/src/index.ts | 4 + packages/sdk/src/spec.ts | 3 +- .../sdk/tests/flow-tool-kernel-live.test.ts | 147 +++++++++ .../sdk/tests/flow-tool-public-api.test.ts | 3 + 7 files changed, 512 insertions(+), 17 deletions(-) create mode 100644 packages/sdk/src/flow-tool-kernel.ts create mode 100644 packages/sdk/tests/flow-tool-kernel-live.test.ts diff --git a/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md b/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md index 47720a893..d36b20f3c 100644 --- a/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md +++ b/packages/sdk/FLOW-TOOLS-IMPLEMENTATION.md @@ -9,20 +9,42 @@ The RFC is the target, not a claim that its hosted acceptance gates have passed. | --- | --- | --- | --- | | Explicit immutable schemas | FlowToolManifestV1, bounded snapshots, canonical digest | Keep backwards-compatible manifest APIs; validate catalog and lifecycle envelopes | Surface authoring headers and signed bundle publication binding | | Scoped discovery | No hosted tool registry | Validated canonical catalog; native/MCP/action views of the same selected revisions | Cloud must filter tenant/workspace/deployment/grants before response | -| Digest-pinned invoke | Local bundle verification; source-based Cloud run is not admission | Mandatory manifest/deployment/flow binding and canonical input hash; no source fallback | Cloud verifies signer trust, sealed bundle and embedded manifest | -| Durable idempotency | Kernel effect keys are not API admission keys | Caller-owned key preserved by every adapter; explicit conflict/ambiguous transport outcomes | Cloud atomic unique scope/key + input-hash ledger and durable launch reconciliation | -| Async lifecycle | Journal protocol run/status/cancel/resume | Stable receipt, strict status/result, replay cursor, bounded observation request, human answer/cancel/resume contracts | Cloud journal projection, durable command authorization and execution | +| Digest-pinned invoke | Local bundle verification; source-based Cloud run is not admission | Mandatory manifest/deployment/flow binding; embedded runtime verifies the exact sealed digest and admits only its closed no-effect template | Cloud verifies signer trust, embedded manifest and general deployed programs | +| Durable idempotency | Kernel admission keys atomically bind a spec | Caller key is scoped by principal/deployment/digest and delegated to kernel admission; different canonical input conflicts | Cloud atomic tenant ledger and durable cross-process launch reconciliation | +| Async lifecycle | Journal protocol run/status/cancel/resume | Stable receipt plus real kernel run/status/event/evidence projection for the conformance program | Cloud journal projection and durable command/human execution | | Terminal verdict/evidence | Run completion reason, journal, spend | Separate platform reason/business enum/result schema; closed redacted evidence references | Trusted result producer, evidence ACL/retention/redaction and journal-digest calculation | | Scope/budget enforcement | Declarations are not enforcement | No caller-selected identity, deployment authority or budget in model arguments; read-only catalog v1 only | Cloud gate-8 credentials/effects enforcement; deny write-enabled tools | | Adapter parity | Descriptor-only functions | Native, MCP and Relay-compatible action handlers call the same client | Actual authenticated server/session/action registration and provider-specific schemas | -| Restart/crash proof | Kernel crash suite | Contract transport conformance with persisted test fixture and client reconstruction | Real admission/process/worker/effect crash tests; live pilot proof | +| Restart/crash proof | Kernel crash suite | Live relayflowd admission, execution, journal replay and principal-bound reconstruction tests | Hosted process/worker/effect crash tests; live pilot proof | Dependency order: (1) strict shared wire contract and schema/binding validators; (2) explicit authenticated transport and canonical client; (3) adapters with host-owned idempotency metadata; (4) negative/parity/replay conformance tests; (5) Cloud admission and projection implementation; (6) non-production read-only pilot plus RFC acceptance gates. PR remains draft until its declared acceptance -is satisfied. Unit/fixture tests do not complete the hosted RFC. +is satisfied. The narrow embedded runtime and fixture tests do not complete the +hosted RFC. + +## Embedded kernel conformance runtime + +`createKernelFlowToolControlPlane` is an actual `FlowToolTransport` over a +connected `JournalClient`, not a manifest adapter or in-memory run ledger. It +verifies the pinned sealed bundle, requires a successful bundled preflight, +reauthorizes discovery/invoke/read operations from server-bound grants, and +uses relayflowd's atomic admission key for durable deduplication. Status, +events, spend, terminal result, and evidence digest are projected from the +journal. Principal identity is hashed into the journaled run binding, allowing +a reconstructed transport to reauthorize old runs without a process-local map. + +The accepted executable contract is deliberately tiny and fail-closed: one +effect-free deterministic step, direct `/usr/bin/printf` or `/bin/printf` argv, +an exact JSON-input placeholder, no shell interpolation, no plugins, agents, +LLMs, triggers, human waits, or effect claims. The runtime substitutes only the +canonical schema-validated JSON argv element and validates stdout against the +manifest result schema. This is a real admission/execution/result path useful +for control-plane conformance; it is not the Babysitter pilot or general Flow +execution. Cancel/resume/human commands are unsupported for this synchronous +one-step program, and the hosted Cloud control plane remains unimplemented. ## Cloud implementation contract (not implemented by this SDK) @@ -46,14 +68,12 @@ Supported HTTP mappings: 400/422 invalid_contract, 401/403 not_authorized, unavailable. Transport failure is separately ambiguous. Run `state:cancelled` maps to terminal reason `canceled`, matching the existing SDK/kernel reason. -The current bundle executor does not admit authored/agent/LLM bundles. The +The general bundle executor does not admit authored/agent/LLM bundles. The build probe rejects nonempty authored headers and the local digest runner only supports declarative deterministic specs without assets/requirements/triggers. -An exact-spec Cloud executor may support a tiny explicitly enforced no-effect -command allowlist (for example literal `true`/`false`) as a control-plane smoke. -That is not the RFC Babysitter pilot. A constant result mapping must be clearly -declared and bound to immutable reviewed policy; completing `true` is not -evidence of PR review or a computed business verdict. General authored artifacts, +The SDK's embedded conformance runtime implements one such exact, no-effect +allowlist while actually passing validated JSON through relayflowd and reading +the result from its journal. That is not the RFC Babysitter pilot. General authored artifacts, surface headers, signed publication and journaled business-output binding remain real implementation dependencies, not capabilities provided by this client. diff --git a/packages/sdk/FLOW-TOOLS.md b/packages/sdk/FLOW-TOOLS.md index fefb449bf..94d0755b1 100644 --- a/packages/sdk/FLOW-TOOLS.md +++ b/packages/sdk/FLOW-TOOLS.md @@ -90,8 +90,10 @@ The authored runtime currently returns a completion reason and journal-step references, not this flow-defined result object or a verified bundle-admission receipt. A callable adapter must first bind verified execution to the manifest, validate input before effects and validate the real result after execution. -The new control-plane client below supplies transport and validation, not the -hosted admission ledger, permission enforcement or execution implementation. +The control-plane client below supplies transport and validation. The optional +embedded conformance runtime supplies a deliberately restricted relayflowd +execution path; it is not the hosted admission ledger or general permission +enforcement. ## Acceptance for this SDK slice @@ -106,9 +108,17 @@ not a hosted end-to-end run or evidence of business-result correctness. The unchanged package and Linux kernel/SDK CI gates protect existing consumers. Passing them does not satisfy the RFC's live workload gates. Execution/admission -and a real callable-flow journey remain separate implementation and acceptance -work: they are prerequisites for shipping an enabled callable product, not -capabilities that this SDK's transport implementation claims to provide. +for arbitrary authored/agent flows and a real callable-flow journey remain +separate implementation and acceptance work. + +For an in-process control-plane conformance path, use +`createKernelFlowToolControlPlane` with a connected `JournalClient`, an +authenticated principal, explicit deployment grants, and a digest-pinned sealed +bundle. It executes only the documented effect-free JSON echo template through +relayflowd. The live test proves kernel admission dedupe, real argv execution, +journal-derived result/evidence/events, input revalidation, and cross-principal +denial. Any broader bundle shape fails before admission; this API must not be +presented as general Flow-as-tool support. ## Canonical control-plane client and call adapters diff --git a/packages/sdk/src/flow-tool-kernel.ts b/packages/sdk/src/flow-tool-kernel.ts new file mode 100644 index 000000000..dfcc74d47 --- /dev/null +++ b/packages/sdk/src/flow-tool-kernel.ts @@ -0,0 +1,310 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { canonicalize } from './canonical.js'; +import { sha256, verifyBundle } from './bundle.js'; +import { FlowToolError, type FlowToolCatalogEntryV1, type FlowToolEventV1, + type FlowToolRunState, type FlowToolRunV1, type FlowToolTerminalReason } from './flow-tool-contract.js'; +import type { FlowToolRequest, FlowToolTransport } from './flow-tool-client.js'; +import { flowToolOperationKey } from './flow-tool-client.js'; +import { JournalClient, JournalProtocolError } from './journal-client.js'; +import { parseFlowToolEntry, parseFlowToolInvocation, flowToolRunLinks, toolId } from './flow-tool-wire.js'; +import { validateFlowToolResult } from './flow-tool-manifest.js'; +import type { FlowToolJson } from './flow-tool-schema.js'; +import type { KernelDeterministicStep, KernelRunSpec } from './spec.js'; + +/** The only executable accepted by the v1 embedded runtime. It receives JSON as argv data. */ +export const FLOW_TOOL_INPUT_PLACEHOLDER = '__RELAYFLOWS_FLOW_TOOL_INPUT_V1__'; +const RESULT_STEP = 'flow-tool-result'; +const MAX_RESULT_BYTES = 60 * 1024; // below relayflowd's 64 KiB stdout tail + +export interface KernelFlowToolDeployment { + readonly entry: FlowToolCatalogEntryV1; + /** A bundle whose content digest is entry.manifest.flow.digest. */ + readonly bundlePath: string; + /** Operator-owned outcome label, never selected by invocation input. */ + readonly businessVerdict: string; +} + +export interface KernelFlowToolControlPlaneOptions { + readonly journal: JournalClient; + /** Authenticated server-side identity. This value is hashed before journaling. */ + readonly principal: string; + readonly deployments: readonly KernelFlowToolDeployment[]; + /** Server-side grants for this principal. Omission grants nothing. */ + readonly authorizedDeploymentIds?: readonly string[]; +} + +interface LoadedDeployment { + entry: FlowToolCatalogEntryV1; + template: KernelRunSpec; + businessVerdict: string; +} + +interface RunBinding { + kind: 'relayflows.flow-tool-run.v1'; + deployment_id: string; + manifest_digest: string; + flow_digest: string; + input_digest: string; + principal_digest: string; + catalog_digest: string; + business_verdict: string; +} + +interface JournalEntry { + seq: number; + entry_type: string; + step_id?: string | null; + payload: Record; +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function runtimeFailure(code: 'unsupported' | 'invalid_contract' = 'unsupported'): never { + throw new FlowToolError(code); +} + +function exactKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every(key => allowed.includes(key)); +} + +function preflightTemplate(value: unknown, entry: FlowToolCatalogEntryV1): KernelRunSpec { + if (!record(value) || !exactKeys(value, ['version', 'name', 'description', 'steps']) + || typeof value['version'] !== 'string' || value['name'] !== entry.manifest.flow.name + || !Array.isArray(value['steps']) || value['steps'].length !== 1) runtimeFailure(); + const step = value['steps'][0]; + if (!record(step) || !exactKeys(step, [ + 'id', 'type', 'command', 'depends_on', 'max_iterations', 'retry', 'verification', 'timeout_ms', + ]) || step['id'] !== RESULT_STEP || step['type'] !== 'deterministic' + || !Array.isArray(step['command']) || step['command'].length !== 3 + || !['/usr/bin/printf', '/bin/printf'].includes(step['command'][0] as string) + || step['command'][1] !== '%s' || step['command'][2] !== FLOW_TOOL_INPUT_PLACEHOLDER + || !Array.isArray(step['depends_on']) || step['depends_on'].length !== 0 + || step['max_iterations'] !== 1 || !record(step['retry']) || !record(step['verification']) + || Object.keys(step['verification']).length !== 0 + || !Number.isSafeInteger(step['timeout_ms']) || (step['timeout_ms'] as number) < 1 + || (step['timeout_ms'] as number) > entry.budget.max_wallclock_ms) runtimeFailure(); + const retry = step['retry']; + if (!exactKeys(retry, ['initial_backoff_ms', 'max_backoff_ms', 'multiplier', 'jitter_percent']) + || retry['initial_backoff_ms'] !== 0 || retry['max_backoff_ms'] !== 0 + || retry['multiplier'] !== 1 || retry['jitter_percent'] !== 0) runtimeFailure(); + return value as unknown as KernelRunSpec; +} + +async function loadDeployment(deployment: KernelFlowToolDeployment): Promise { + let entry: FlowToolCatalogEntryV1; + try { entry = parseFlowToolEntry(deployment.entry); } catch { runtimeFailure('invalid_contract'); } + if (entry.effects.length !== 0 || entry.requires_human.length !== 0 + || !entry.business_verdicts.includes(deployment.businessVerdict)) runtimeFailure(); + const expected = entry.manifest.flow.digest.slice('sha256:'.length); + try { + const before = await readFile(join(deployment.bundlePath, 'spec.canonical.json')); + const preflightBefore = await readFile(join(deployment.bundlePath, 'preflight.json')); + await verifyBundle(deployment.bundlePath, expected); + const after = await readFile(join(deployment.bundlePath, 'spec.canonical.json')); + const preflightAfter = await readFile(join(deployment.bundlePath, 'preflight.json')); + if (!before.equals(after) || !preflightBefore.equals(preflightAfter)) runtimeFailure(); + const report: unknown = JSON.parse(preflightAfter.toString('utf8')); + if (!record(report) || report['ok'] !== true) runtimeFailure(); + return { entry, template: preflightTemplate(JSON.parse(after.toString('utf8')), entry), + businessVerdict: deployment.businessVerdict }; + } catch (error) { + if (error instanceof FlowToolError) throw error; + runtimeFailure(); + } +} + +function bindingDescription(binding: RunBinding): string { return canonicalize(binding); } + +function parseBinding(entries: readonly JournalEntry[]): RunBinding { + const spawned = entries.find(entry => entry.entry_type === 'run.spawned'); + const spec = spawned?.payload['spec']; + const description = record(spec) ? spec['description'] : undefined; + try { + const value: unknown = JSON.parse(typeof description === 'string' ? description : 'null'); + if (!record(value) || value['kind'] !== 'relayflows.flow-tool-run.v1' + || typeof value['deployment_id'] !== 'string' || typeof value['manifest_digest'] !== 'string' + || typeof value['flow_digest'] !== 'string' || typeof value['input_digest'] !== 'string' + || typeof value['principal_digest'] !== 'string' || typeof value['catalog_digest'] !== 'string' + || typeof value['business_verdict'] !== 'string' + || !/^sha256:[a-f0-9]{64}$/.test(value['manifest_digest']) + || !/^sha256:[a-f0-9]{64}$/.test(value['flow_digest']) + || !/^sha256:[a-f0-9]{64}$/.test(value['input_digest']) + || !/^[a-f0-9]{64}$/.test(value['principal_digest']) + || !/^[a-f0-9]{64}$/.test(value['catalog_digest'])) runtimeFailure('invalid_contract'); + return value as unknown as RunBinding; + } catch (error) { + if (error instanceof FlowToolError) throw error; + runtimeFailure('invalid_contract'); + } +} + +function journalEntries(value: unknown[]): JournalEntry[] { + if (!value.every(item => record(item) && Number.isSafeInteger(item['seq']) + && typeof item['entry_type'] === 'string' && record(item['payload']))) runtimeFailure('invalid_contract'); + return value as unknown as JournalEntry[]; +} + +function terminalReason(reason: unknown): FlowToolTerminalReason { + if (reason === 'success') return 'success'; + if (reason === 'canceled') return 'canceled'; + if (reason === 'budget_exceeded') return 'budget_exceeded'; + return 'execution_failed'; +} + +function runState(status: string, reason: FlowToolTerminalReason | null): FlowToolRunState { + if (reason === 'success') return 'completed'; + if (reason === 'canceled') return 'cancelled'; + if (reason !== null) return 'failed'; + if (status === 'parked') return 'parked'; + return 'running'; +} + +function mapProtocolError(error: unknown, admission = false): never { + if (error instanceof JournalProtocolError) { + if (error.code === 'run_admission_conflict') throw new FlowToolError('idempotency_conflict'); + if (error.code === 'run_not_found') throw new FlowToolError('not_authorized'); + throw new FlowToolError('unavailable'); + } + throw new FlowToolError(admission ? 'transport_error' : 'unavailable'); +} + +/** + * Build an authenticated embedded control plane over relayflowd's durable journal. + * This intentionally supports only the effect-free JSON echo/conformance program; + * arbitrary declarative, authored, LLM, and agent flows remain unsupported. + */ +export async function createKernelFlowToolControlPlane( + options: KernelFlowToolControlPlaneOptions, +): Promise { + if (typeof options.principal !== 'string' || options.principal.length < 1 || options.principal.length > 1024) { + runtimeFailure('invalid_contract'); + } + const principalDigest = sha256(options.principal); + const grants = new Set(options.authorizedDeploymentIds ?? []); + const deployments = new Map(); + const names = new Map(); + for (const candidate of options.deployments) { + const loaded = await loadDeployment(candidate); + if (deployments.has(loaded.entry.deployment_id) || names.has(loaded.entry.manifest.name)) runtimeFailure('invalid_contract'); + deployments.set(loaded.entry.deployment_id, loaded); + names.set(loaded.entry.manifest.name, loaded); + } + + const authorized = (deployment: LoadedDeployment): boolean => grants.has(deployment.entry.deployment_id); + + async function readAuthorizedRun(runId: string) { + toolId(runId); + let entries: JournalEntry[]; + try { entries = journalEntries((await options.journal.journalRead(runId, 1, 1000)).entries); } + catch (error) { mapProtocolError(error); } + const binding = parseBinding(entries!); + const deployment = deployments.get(binding.deployment_id); + if (deployment === undefined || !authorized(deployment) || binding.principal_digest !== principalDigest + || binding.manifest_digest !== deployment.entry.manifest.digest + || binding.flow_digest !== deployment.entry.manifest.flow.digest + || binding.catalog_digest !== sha256(canonicalize(deployment.entry)) + || binding.business_verdict !== deployment.businessVerdict) throw new FlowToolError('not_authorized'); + return { deployment, binding, entries: entries! }; + } + + async function projectRun(runId: string): Promise { + const { deployment, binding, entries } = await readAuthorizedRun(runId); + let snapshot: Awaited>; + try { snapshot = await options.journal.runGet(runId); } catch (error) { mapProtocolError(error); } + const completed = entries.find(entry => entry.entry_type === 'step.completed' && entry.step_id === RESULT_STEP); + const ended = entries.find(entry => entry.entry_type === 'run.completed'); + let reason = ended === undefined ? null : terminalReason(ended.payload['completionReason']); + if (reason === null && ['failed', 'interrupted'].includes(snapshot!.status)) reason = 'execution_failed'; + let result: Readonly> | null = null; + if (reason === 'success') { + try { + const output = completed?.payload['output']; + if (!record(output) || typeof output['stdout_tail'] !== 'string') throw new Error('missing output'); + result = validateFlowToolResult(deployment.entry.manifest, JSON.parse(output['stdout_tail'])); + } catch { reason = 'execution_failed'; } + } + const state = runState(snapshot!.status, reason); + const sequence = Math.max(0, ...entries.map(entry => entry.seq)); + const evidence = { journal_digest: `sha256:${sha256(canonicalize(entries))}` as const, + flow_digest: deployment.entry.manifest.flow.digest, artifacts: [], redacted_transcript_refs: [] }; + const terminal = reason === null ? null : { + terminal_reason: reason, + business_verdict: reason === 'success' ? deployment.businessVerdict : null, + result: reason === 'success' ? result : null, + gates: [], evidence, + spend: { tokens_in: snapshot!.budget.tokens_in, tokens_out: snapshot!.budget.tokens_out, + dollars: snapshot!.budget.dollars, dollars_unmetered: snapshot!.budget.dollars_unmetered === true }, + }; + return { api_version: 1, accepted: true, run_id: runId, tool_name: deployment.entry.manifest.name, + deployment_id: deployment.entry.deployment_id, manifest_digest: deployment.entry.manifest.digest, + flow_digest: deployment.entry.manifest.flow.digest, input_digest: binding.input_digest as `sha256:${string}`, + state, sequence, ...flowToolRunLinks(runId), terminal }; + } + + function eventsFor(run: FlowToolRunV1, entries: readonly JournalEntry[], after: number): FlowToolEventV1[] { + const projected: FlowToolEventV1[] = []; + for (const entry of entries) { + if (entry.seq <= after) continue; + if (entry.entry_type === 'run.spawned') projected.push({ api_version: 1, run_id: run.run_id, + flow_digest: run.flow_digest, sequence: entry.seq, type: 'run.accepted', state: 'accepted' }); + else if (entry.entry_type === 'step.completed') projected.push({ api_version: 1, run_id: run.run_id, + flow_digest: run.flow_digest, sequence: entry.seq, type: 'step.completed', state: 'running', + ...(entry.step_id === null || entry.step_id === undefined ? {} : { step_id: entry.step_id }) }); + else if (entry.entry_type === 'run.completed') projected.push({ api_version: 1, run_id: run.run_id, + flow_digest: run.flow_digest, sequence: entry.seq, type: 'run.terminal', state: run.state, + terminal_reason: run.terminal!.terminal_reason }); + } + return projected; + } + + return { + async request(request: FlowToolRequest): Promise { + if (request.method === 'GET' && request.path === '/api/v1/flow-tools') { + return { api_version: 1, tools: [...deployments.values()].filter(authorized).map(value => value.entry) }; + } + const invoke = request.path.match(/^\/api\/v1\/flow-tools\/([^/]+)\/invoke$/); + if (request.method === 'POST' && invoke !== null) { + const deployment = names.get(invoke[1]!); + if (deployment === undefined || !authorized(deployment)) throw new FlowToolError('not_authorized'); + flowToolOperationKey(request.idempotencyKey); + const body = parseFlowToolInvocation(request.body, deployment.entry); + const encoded = canonicalize(body.input); + if (Buffer.byteLength(encoded) > MAX_RESULT_BYTES) throw new FlowToolError('unsupported'); + const binding: RunBinding = { kind: 'relayflows.flow-tool-run.v1', deployment_id: deployment.entry.deployment_id, + manifest_digest: deployment.entry.manifest.digest, flow_digest: deployment.entry.manifest.flow.digest, + input_digest: body.input_digest, principal_digest: principalDigest, + catalog_digest: sha256(canonicalize(deployment.entry)), business_verdict: deployment.businessVerdict }; + const step = deployment.template.steps[0]! as KernelDeterministicStep; + const spec: KernelRunSpec = { ...deployment.template, description: bindingDescription(binding), steps: [{ + ...step, command: [(step.command as string[])[0]!, '%s', encoded], + }] }; + const admissionKey = `flow-tool:${sha256(canonicalize([principalDigest, deployment.entry.deployment_id, + deployment.entry.manifest.flow.digest, deployment.entry.manifest.digest, request.idempotencyKey]))}`; + let started: Awaited>; + try { started = await options.journal.runStart(spec, undefined, admissionKey); } + catch (error) { mapProtocolError(error, true); } + return projectRun(started!.run_id); + } + const runPath = request.path.match(/^\/api\/v1\/flow-runs\/([^/]+)(.*)$/); + if (runPath === null) throw new FlowToolError('not_found'); + const run = await projectRun(runPath[1]!); + if (request.method === 'GET' && runPath[2] === '') return run; + if (request.method === 'GET' && runPath[2] === '/evidence') { + if (run.terminal === null) throw new FlowToolError('unavailable'); + return { api_version: 1, run_id: run.run_id, evidence: run.terminal.evidence }; + } + // The conformance program cannot park, so commands are deliberately absent. + throw new FlowToolError('unsupported'); + }, + async *events(path: string, after: number): AsyncIterable { + const match = path.match(/^\/api\/v1\/flow-runs\/([^/]+)\/events$/); + if (match === null || !Number.isSafeInteger(after) || after < 0) throw new FlowToolError('invalid_contract'); + const { entries } = await readAuthorizedRun(match[1]!); + const run = await projectRun(match[1]!); + for (const event of eventsFor(run, entries, after)) yield event; + }, + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5e22a2566..d24f50a6c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -292,6 +292,10 @@ export { parseFlowToolCatalog, canonicalFlowToolCatalog, parseFlowToolEntry, par export { FlowToolClient, type FlowToolTransport, type FlowToolRequest, type FlowToolInvocationOptions } from './flow-tool-client.js'; export { createFlowToolHttpTransport, type FlowToolHttpOptions } from './flow-tool-http.js'; export { createFlowToolAdapters } from './flow-tool-adapters.js'; +export { + createKernelFlowToolControlPlane, FLOW_TOOL_INPUT_PLACEHOLDER, + type KernelFlowToolDeployment, type KernelFlowToolControlPlaneOptions, +} from './flow-tool-kernel.js'; export { renderProgress, type ProgressEvent } from './progress.js'; export { webhookTriggerSpec } from './trigger-executor.js'; diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index b06cf4e76..033c59ffb 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -478,7 +478,8 @@ export interface KernelStepCommon { export interface KernelDeterministicStep extends KernelStepCommon { type: 'deterministic'; - command: string; + /** Shell source, or argv executed directly without shell interpolation. */ + command: string | string[]; timeout_ms?: number; lease_ms?: number; /** diff --git a/packages/sdk/tests/flow-tool-kernel-live.test.ts b/packages/sdk/tests/flow-tool-kernel-live.test.ts new file mode 100644 index 000000000..afa92421c --- /dev/null +++ b/packages/sdk/tests/flow-tool-kernel-live.test.ts @@ -0,0 +1,147 @@ +import { accessSync, constants, existsSync, lstatSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { canonicalize } from '../src/canonical.js'; +import { sealBundle } from '../src/bundle.js'; +import { socketPathFor } from '../src/daemon-connection.js'; +import { FlowToolClient } from '../src/flow-tool-client.js'; +import { createKernelFlowToolControlPlane, FLOW_TOOL_INPUT_PLACEHOLDER } from '../src/flow-tool-kernel.js'; +import { createFlowToolManifest } from '../src/flow-tool-manifest.js'; +import { JournalClient } from '../src/journal-client.js'; + +const target = process.env['CARGO_TARGET_DIR'] + ?? join(process.env['RELAYFLOWS_TOOLCHAIN_HOME'] ?? join(homedir(), '.relayflows-toolchain'), 'target'); +function locateRelayflowd(): string { + const direct = join(target, 'debug', 'relayflowd'); + if (existsSync(direct)) return direct; + return readdirSync(target).map(name => join(target, name, 'debug', 'relayflowd')) + .filter(path => existsSync(path)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]!; +} +const binary = process.env['RELAYFLOWD_BIN'] ?? locateRelayflowd(); +const directories: string[] = []; +const daemons: ChildProcess[] = []; +const clients: JournalClient[] = []; + +beforeAll(() => accessSync(binary, constants.X_OK)); +afterEach(async () => { + for (const client of clients.splice(0)) client.close(); + for (const daemon of daemons.splice(0)) { + if (daemon.exitCode === null && daemon.signalCode === null) { + const stopped = new Promise(resolve => daemon.once('exit', () => resolve())); + daemon.kill('SIGTERM'); await stopped; + } + } + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +async function daemonAndJournal() { + const dataDir = mkdtempSync(join(tmpdir(), 'flow-tool-kernel-data-')); directories.push(dataDir); + const daemon = spawn(binary, ['--data-dir', dataDir, 'serve'], { stdio: ['ignore', 'pipe', 'pipe'] }); + daemons.push(daemon); + const socket = socketPathFor(dataDir), deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (existsSync(socket) && lstatSync(socket).isSocket()) { + const client = new JournalClient(socket, { requestTimeoutMs: 5_000 }); + try { await client.connect(); await client.hello('flow-tool-kernel-live'); clients.push(client); return client; } + catch { client.close(); } + } + await delay(20); + } + throw new Error('relayflowd did not become ready'); +} + +async function deployment() { + const root = mkdtempSync(join(tmpdir(), 'flow-tool-kernel-bundle-')); directories.push(root); + const spec = { version: '0.1.0', name: 'echo-json', steps: [{ + id: 'flow-tool-result', type: 'deterministic', + command: ['/usr/bin/printf', '%s', FLOW_TOOL_INPUT_PLACEHOLDER], depends_on: [], max_iterations: 1, + retry: { initial_backoff_ms: 0, max_backoff_ms: 0, multiplier: 1, jitter_percent: 0 }, + verification: {}, timeout_ms: 2_000, + }] }; + const bundlePath = await sealBundle({ name: 'echo-json', out: root, repo: root, env: { + FLOWS_BUILD_KEY: Buffer.alloc(32, 9).toString('base64'), + }, warn: () => {}, files: [ + { path: 'spec.canonical.json', data: canonicalize(spec) }, + { path: 'preflight.json', data: canonicalize({ ok: true, diagnostics: [] }) }, + { path: 'lockfile.json', data: canonicalize({ version: 2, plugins: [] }) }, + ] }); + const digest = basename(bundlePath).split('@sha256:')[1]!; + const entry = { + manifest: createFlowToolManifest({ name: 'echo_json', description: 'Effect-free kernel conformance tool.', + flow: { name: 'echo-json', version: '1.0.0', digest: `sha256:${digest}` }, + inputSchema: { type: 'object', properties: { message: { type: 'string', maxLength: 1000 } }, + required: ['message'], additionalProperties: false }, + resultSchema: { type: 'object', properties: { message: { type: 'string', maxLength: 1000 } }, + required: ['message'], additionalProperties: false } }), + deployment_id: 'echo_deployment', read_only: true as const, effects: [], requires_human: [], + business_verdicts: ['echoed'], budget: { max_tokens: 1, max_dollars: '0', max_wallclock_ms: 2_000 }, + }; + return { entry, bundlePath, businessVerdict: 'echoed' }; +} + +describe('kernel-backed Flow Tool control plane', () => { + it('authenticates, admits once, executes input, and projects terminal journal evidence', async () => { + const journal = await daemonAndJournal(), deployed = await deployment(); + const transport = await createKernelFlowToolControlPlane({ journal, principal: 'tenant-a/principal-a', + deployments: [deployed], authorizedDeploymentIds: ['echo_deployment'] }); + const client = new FlowToolClient(transport); + expect((await client.discover()).tools).toEqual([deployed.entry]); + const first = await client.invoke(deployed.entry, { message: 'quotes " and $() stay data' }, { idempotencyKey: 'operation-1' }); + expect(first).toMatchObject({ state: 'completed', terminal: { terminal_reason: 'success', + business_verdict: 'echoed', result: { message: 'quotes " and $() stay data' } } }); + const repeated = await client.invoke(deployed.entry, { message: 'quotes " and $() stay data' }, { idempotencyKey: 'operation-1' }); + expect(repeated.run_id).toBe(first.run_id); + await expect(client.invoke(deployed.entry, { message: 'different' }, { idempotencyKey: 'operation-1' })) + .rejects.toMatchObject({ code: 'idempotency_conflict' }); + const entries = (await journal.journalRead(first.run_id, 1, 1000)).entries as Array<{ entry_type: string }>; + expect(entries.filter(item => item.entry_type === 'run.spawned')).toHaveLength(1); + expect(entries.map(item => item.entry_type)).toEqual(expect.arrayContaining(['step.completed', 'run.completed'])); + const events = []; for await (const event of client.events(deployed.entry, first)) events.push(event); + expect(events.map(event => event.type)).toEqual(['run.accepted', 'step.completed', 'run.terminal']); + expect(await client.evidence(deployed.entry, first)).toEqual(first.terminal?.evidence); + }, 15_000); + + it('filters discovery and rejects cross-principal observation from journal-bound metadata', async () => { + const journal = await daemonAndJournal(), deployed = await deployment(); + const allowed = new FlowToolClient(await createKernelFlowToolControlPlane({ journal, principal: 'principal-a', + deployments: [deployed], authorizedDeploymentIds: ['echo_deployment'] })); + const run = await allowed.invoke(deployed.entry, { message: 'secret' }, { idempotencyKey: 'operation-2' }); + const reconstructed = new FlowToolClient(await createKernelFlowToolControlPlane({ journal, principal: 'principal-a', + deployments: [deployed], authorizedDeploymentIds: ['echo_deployment'] })); + expect((await reconstructed.status(deployed.entry, run)).run_id).toBe(run.run_id); + const denied = new FlowToolClient(await createKernelFlowToolControlPlane({ journal, principal: 'principal-b', + deployments: [deployed], authorizedDeploymentIds: ['echo_deployment'] })); + await expect(denied.status(deployed.entry, run)).rejects.toMatchObject({ code: 'not_authorized' }); + const ungranted = new FlowToolClient(await createKernelFlowToolControlPlane({ journal, principal: 'principal-a', + deployments: [deployed] })); + expect((await ungranted.discover()).tools).toEqual([]); + await expect(ungranted.invoke(deployed.entry, { message: 'secret' }, { idempotencyKey: 'operation-3' })) + .rejects.toMatchObject({ code: 'not_authorized' }); + }, 15_000); + + it('fails closed during bundle preflight before kernel admission', async () => { + const journal = await daemonAndJournal(), deployed = await deployment(); + const starts = vi.spyOn(journal, 'runStart'); + const unsafe = { ...deployed, entry: { ...deployed.entry, effects: ['github:read'] } }; + await expect(createKernelFlowToolControlPlane({ journal, principal: 'principal-a', deployments: [unsafe], + authorizedDeploymentIds: ['echo_deployment'] })).rejects.toMatchObject({ code: 'unsupported' }); + expect(starts).not.toHaveBeenCalled(); + }, 15_000); + + it('revalidates a direct transport invocation before journal admission', async () => { + const journal = await daemonAndJournal(), deployed = await deployment(); + const starts = vi.spyOn(journal, 'runStart'); + const transport = await createKernelFlowToolControlPlane({ journal, principal: 'principal-a', + deployments: [deployed], authorizedDeploymentIds: ['echo_deployment'] }); + await expect(transport.request({ method: 'POST', path: '/api/v1/flow-tools/echo_json/invoke', + idempotencyKey: 'operation-raw', body: { + api_version: 1, flow: `${deployed.entry.manifest.flow.name}@${deployed.entry.manifest.flow.digest}`, + deployment_id: deployed.entry.deployment_id, manifest_digest: deployed.entry.manifest.digest, + input: { message: 42 }, input_digest: `sha256:${'0'.repeat(64)}`, mode: 'async', wait_ms: 0, + } })).rejects.toMatchObject({ code: 'invalid_contract' }); + expect(starts).not.toHaveBeenCalled(); + }, 15_000); +}); diff --git a/packages/sdk/tests/flow-tool-public-api.test.ts b/packages/sdk/tests/flow-tool-public-api.test.ts index 37b662f3a..686fe1c9a 100644 --- a/packages/sdk/tests/flow-tool-public-api.test.ts +++ b/packages/sdk/tests/flow-tool-public-api.test.ts @@ -12,6 +12,7 @@ it('round-trips the manifest contract through the built public SDK without execu validateFlowToolInput, validateFlowToolResult, flowToolFunctionDefinition, flowToolMcpDefinition, FlowToolClient, createFlowToolAdapters, flowToolRunLinks, flowToolInputDigest, + createKernelFlowToolControlPlane, FLOW_TOOL_INPUT_PLACEHOLDER, } from ${JSON.stringify(sdkUrl)}; const manifest = createFlowToolManifest({ @@ -55,6 +56,8 @@ it('round-trips the manifest contract through the built public SDK without execu }); assert.equal('annotations' in mcp, false); assert.equal('strict' in native, false); + assert.equal(typeof createKernelFlowToolControlPlane, 'function'); + assert.equal(FLOW_TOOL_INPUT_PLACEHOLDER, '__RELAYFLOWS_FLOW_TOOL_INPUT_V1__'); // A transport fixture exercises public client exports, not Cloud or a real run. const selected = { manifest: restored, deployment_id: 'test_deployment', read_only: true, From 921645affc8be09bba95574a1db3c4a2404d83b7 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 14:04:31 -0700 Subject: [PATCH 6/6] fix(sdk): keep flow tool argv internal Session-Id: 01a0d525-feb9-77e3-9f0c-a5fcb22f7d79 --- packages/sdk/src/flow-tool-kernel.ts | 11 ++++++++--- packages/sdk/src/spec.ts | 3 +-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/flow-tool-kernel.ts b/packages/sdk/src/flow-tool-kernel.ts index dfcc74d47..3b30772f9 100644 --- a/packages/sdk/src/flow-tool-kernel.ts +++ b/packages/sdk/src/flow-tool-kernel.ts @@ -40,6 +40,11 @@ interface LoadedDeployment { businessVerdict: string; } +// relayflowd's Rust boundary accepts an untagged shell-string | argv command. +// The public authoring compiler deliberately remains shell-string-only; this +// closed runtime constructs the narrower argv variant internally. +type KernelArgvStep = Omit & { command: string[] }; + interface RunBinding { kind: 'relayflows.flow-tool-run.v1'; deployment_id: string; @@ -277,10 +282,10 @@ export async function createKernelFlowToolControlPlane( manifest_digest: deployment.entry.manifest.digest, flow_digest: deployment.entry.manifest.flow.digest, input_digest: body.input_digest, principal_digest: principalDigest, catalog_digest: sha256(canonicalize(deployment.entry)), business_verdict: deployment.businessVerdict }; - const step = deployment.template.steps[0]! as KernelDeterministicStep; + const step = deployment.template.steps[0]! as unknown as KernelArgvStep; const spec: KernelRunSpec = { ...deployment.template, description: bindingDescription(binding), steps: [{ - ...step, command: [(step.command as string[])[0]!, '%s', encoded], - }] }; + ...step, command: [step.command[0]!, '%s', encoded], + } as unknown as KernelDeterministicStep] }; const admissionKey = `flow-tool:${sha256(canonicalize([principalDigest, deployment.entry.deployment_id, deployment.entry.manifest.flow.digest, deployment.entry.manifest.digest, request.idempotencyKey]))}`; let started: Awaited>; diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index 033c59ffb..b06cf4e76 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -478,8 +478,7 @@ export interface KernelStepCommon { export interface KernelDeterministicStep extends KernelStepCommon { type: 'deterministic'; - /** Shell source, or argv executed directly without shell interpolation. */ - command: string | string[]; + command: string; timeout_ms?: number; lease_ms?: number; /**