Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions typescript/core/web_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

- Function calls now await pending Promises in their arguments before validating and running, while synchronous calls remain synchronous. [#3578](https://github.com/a2ui-project/a2ui/pull/3578)
- `@a2ui/web_core/v1_0` exports `isSafeRegex` and `SafeRegexOptions`, the ReDoS check the basic catalog's `regex` function uses, so v1.0 catalog packages can validate agent-supplied patterns without importing `/v0_9`. [#3572](https://github.com/a2ui-project/a2ui/pull/3572)
- Pass literal `'aria-label'`, `'aria-description'`, and `'aria-live'` attribute names to `this.setAttribute(...)` in `BasicCatalogA2uiLitElement.applyAccessibilityAttributes`, construct Unicode property escape regular expressions via `new RegExp(..., 'u')` in `uax31.ts` and `expression_parser.ts`, and delegate `ExtensionsSchema` key validation in `common-types.ts` to `isValidUax31Identifier`, improving compatibility with Trusted Types `setAttribute` static analyzers and downstream JavaScript compilers.
- `Catalog.toJson()` and `Catalog.validationSchema` write a `z.union` as `anyOf` instead of `oneOf`, so a catalog reloaded with `Catalog.fromJson` accepts the values the original accepts, such as `"md"` for `z.union([z.enum(['md', 'lg']), z.string()])` ([#3362](https://github.com/a2ui-project/a2ui/issues/3362)). Specification-shaped models (`spec()` with `mixins`), including the built-in basic catalogs, still write a union as `oneOf` when no value can match two of its branches. A `oneOf` loaded with `Catalog.fromJson` stays exclusive and is written back as `oneOf`.
Expand Down
79 changes: 79 additions & 0 deletions typescript/core/web_core/src/catalog/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,85 @@ describe('Catalog Types', () => {
assert.ok(defs);
assert.ok(defs.DynamicString);
});

describe('invoker with pending arguments', () => {
const upper = createFunctionImplementation(
{name: 'upper', returnType: 'string', schema: z.object({'value': z.string()})},
args => args.value.toUpperCase(),
);
const catalog = new Catalog('test-cat', '1.0', [], [upper]);

it('runs synchronously when every argument is settled', () => {
assert.strictEqual(catalog.invoker('upper', {value: 'abc'}, {} as any), 'ABC');
});

it('awaits a pending argument before validating against a z.string() schema', async () => {
const result = catalog.invoker('upper', {value: Promise.resolve('abc')}, {} as any);
assert.strictEqual(typeof (result as Promise<unknown>).then, 'function');
assert.strictEqual(await result, 'ABC');
});

it('rejects with the validation error once a pending argument settles to the wrong type', async () => {
await assert.rejects(
catalog.invoker('upper', {value: Promise.resolve(42)}, {} as any) as Promise<unknown>,
(err: any) =>
err instanceof A2uiExpressionError &&
err.message.includes('Validation failed') &&
err.expression === 'upper',
);
});

it('propagates the rejection of a pending argument', async () => {
const failure = new Error('inner failure');
await assert.rejects(
catalog.invoker('upper', {value: Promise.reject(failure)}, {} as any) as Promise<unknown>,
failure,
);
});

it('resolves to undefined when aborted before the arguments settle', async () => {
const abortController = new AbortController();
let executed = false;
const spy = createFunctionImplementation(
{name: 'spy', returnType: 'string', schema: z.object({'value': z.string()})},
() => {
executed = true;
return 'ran';
},
);
const spyCatalog = new Catalog('test-cat', '1.0', [], [spy]);
const result = spyCatalog.invoker(
'spy',
{value: Promise.resolve('abc')},
{} as any,
abortController.signal,
);
abortController.abort();
assert.strictEqual(await result, undefined);
assert.strictEqual(executed, false);
});

it('enforces user activation synchronously, before awaiting any argument', () => {
const guarded = createFunctionImplementation(
{
name: 'guarded',
returnType: 'string',
schema: z.object({'value': z.string()}),
requiresUserActivation: true,
},
() => 'ran',
);
const guardedCatalog = new Catalog('test-cat', '1.0', [], [guarded]);
assert.throws(
() =>
guardedCatalog.invoker('guarded', {value: Promise.resolve('abc')}, {
isPassiveEvaluation: true,
} as any),
(err: any) =>
err instanceof A2uiExpressionError && err.message.includes('requires user activation'),
);
});
});
});

describe('InferredComponentApiSchemaType', () => {
Expand Down
36 changes: 25 additions & 11 deletions typescript/core/web_core/src/catalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {DataContext} from '../resolution/data-context.js';
import {Signal} from '../reactivity/signals.js';
import {A2uiCatalogError, A2uiExpressionError} from '../errors.js';
import {isAtLeastVersion} from '../common/semver.js';
import {isThenable, settleDeep} from '../common/settle_deep.js';
import {SpecVersion} from '../spec_versions.js';
import {
hasCrossDocumentRefs,
Expand Down Expand Up @@ -941,19 +942,32 @@ function createCatalogInvoker<F extends FunctionApi>(
);
}

try {
const safeArgs = fn.schema.parse(rawArgs);
return execute.call(fn, safeArgs, ctx, abortSignal);
} catch (e: any) {
if (e?.name === 'ZodError' || e instanceof z.ZodError) {
throw new A2uiExpressionError(
`Validation failed for function '${name}': ${e.message}`,
name,
e.errors ?? e.issues,
);
const parseAndExecute = (resolvedArgs: unknown): unknown => {
try {
const safeArgs = fn.schema.parse(resolvedArgs);
return execute.call(fn, safeArgs, ctx, abortSignal);
} catch (e: any) {
if (e?.name === 'ZodError' || e instanceof z.ZodError) {
throw new A2uiExpressionError(
`Validation failed for function '${name}': ${e.message}`,
name,
e.errors ?? e.issues,
);
}
throw e;
}
throw e;
};

// An argument produced by an asynchronous function arrives as a pending
// Promise. Validation and execution wait for every such argument, while
// fully settled arguments keep the call synchronous.
const settledArgs = settleDeep(rawArgs);
if (!isThenable(settledArgs)) {
return parseAndExecute(settledArgs);
}
return Promise.resolve(settledArgs).then(resolvedArgs =>
abortSignal?.aborted ? undefined : parseAndExecute(resolvedArgs),
);
};
}

Expand Down
117 changes: 117 additions & 0 deletions typescript/core/web_core/src/common/settle_deep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as assert from 'node:assert';
import {describe, it} from 'node:test';
import {signal} from '../reactivity/signals.js';
import {isPlainObject, isThenable, settleDeep} from './settle_deep.js';

describe('isThenable', () => {
it('recognizes Promises and objects with a callable then', () => {
assert.strictEqual(isThenable(Promise.resolve(1)), true);
assert.strictEqual(isThenable({then: () => {}}), true);
});

it('rejects everything else', () => {
assert.strictEqual(isThenable(null), false);
assert.strictEqual(isThenable(undefined), false);
assert.strictEqual(isThenable('then'), false);
assert.strictEqual(isThenable({then: 1}), false);
assert.strictEqual(isThenable([]), false);
});
});

describe('isPlainObject', () => {
it('accepts object literals and null-prototype objects', () => {
assert.strictEqual(isPlainObject({a: 1}), true);
assert.strictEqual(isPlainObject(Object.create(null)), true);
});

it('rejects arrays, null, primitives and class instances', () => {
assert.strictEqual(isPlainObject([]), false);
assert.strictEqual(isPlainObject(null), false);
assert.strictEqual(isPlainObject('x'), false);
assert.strictEqual(isPlainObject(new Date(0)), false);
assert.strictEqual(isPlainObject(signal(1)), false);
});
});

describe('settleDeep', () => {
it('returns primitives unchanged and synchronously', () => {
assert.strictEqual(settleDeep(1), 1);
assert.strictEqual(settleDeep('a'), 'a');
assert.strictEqual(settleDeep(null), null);
assert.strictEqual(settleDeep(undefined), undefined);
assert.strictEqual(settleDeep(false), false);
});

it('returns the same array and plain object when nothing is pending', () => {
const list = [1, {a: [2, 3]}];
const record = {a: 1, b: {c: [null, 'd']}};
assert.strictEqual(settleDeep(list), list);
assert.strictEqual(settleDeep(record), record);
});

it('returns class instances as they are without walking them', () => {
const date = new Date(0);
const sig = signal(Promise.resolve(1));
assert.strictEqual(settleDeep(date), date);
assert.strictEqual(settleDeep(sig), sig);
const holder = {date, sig};
assert.strictEqual(settleDeep(holder), holder);
});

it('awaits a top-level Promise', async () => {
assert.strictEqual(await settleDeep(Promise.resolve('x')), 'x');
});

it('awaits Promises nested in arrays and plain objects', async () => {
const settled = await settleDeep({
a: Promise.resolve(1),
b: [2, Promise.resolve(3)],
c: {d: Promise.resolve({e: Promise.resolve(4)})},
f: 'static',
});
assert.deepStrictEqual(settled, {a: 1, b: [2, 3], c: {d: {e: 4}}, f: 'static'});
});

it('settles a chain of Promises that resolve to Promises', async () => {
const chained = Promise.resolve(Promise.resolve([Promise.resolve(1)]));
assert.deepStrictEqual(await settleDeep(chained), [1]);
});

it('keeps the keys of a settled object in order', async () => {
const settled = (await settleDeep({z: Promise.resolve(1), a: 2})) as Record<string, unknown>;
assert.deepStrictEqual(Object.keys(settled), ['z', 'a']);
});

it('rejects when a nested Promise rejects', async () => {
const failure = new Error('boom');
await assert.rejects(
settleDeep({ok: Promise.resolve(1), bad: [Promise.reject(failure)]}) as Promise<unknown>,
failure,
);
});

it('does not mutate the input while settling', async () => {
const pending = Promise.resolve(1);
const input = {a: pending, b: [pending]};
const settled = await settleDeep(input);
assert.notStrictEqual(settled, input);
assert.strictEqual(input.a, pending);
assert.strictEqual(input.b[0], pending);
});
});
87 changes: 87 additions & 0 deletions typescript/core/web_core/src/common/settle_deep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Settling of pending values nested in resolved function arguments.
*
* The runtime resolves the arguments of a function call before invoking it.
* An argument produced by an asynchronous function arrives as a pending
* Promise, possibly nested in an array or a plain object built from other
* arguments. `settleDeep` awaits every such Promise before the arguments are
* validated and the function runs, and returns the input itself, synchronously,
* when nothing in it is pending, so synchronous expressions stay synchronous.
*/

/**
* Checks whether a value is a Promise-like object.
*
* @param value The value to inspect.
* @returns Whether `value` has a callable `then`.
*/
export function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as {then?: unknown}).then === 'function'
);
}

/**
* Checks whether a value is a plain object: not an array, `null`, or an
* instance of a class such as `Date` or a `Signal`.
*
* @param value The value to inspect.
* @returns Whether `value` is a plain object.
*/
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (Array.isArray(value) || typeof value !== 'object' || value === null) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/**
* Recursively awaits the Promises nested in the arrays and plain objects of
* `value`.
*
* Only arrays and plain objects are walked; a `Date`, a `Signal`, or any other
* class instance is returned as it is. A rejected Promise anywhere in `value`
* rejects the returned Promise.
*
* @param value The value to settle.
* @returns `value` itself when nothing in it is pending, otherwise a Promise
* of `value` with every nested Promise replaced by its settled value.
*/
export function settleDeep<T>(value: T): T | Promise<unknown> {
if (isThenable(value)) {
return Promise.resolve(value).then(settleDeep);
}
if (Array.isArray(value)) {
const items = value.map(settleDeep);
return items.some(isThenable) ? Promise.all(items) : value;
}
if (isPlainObject(value)) {
const entries = Object.entries(value).map(([key, item]) => [key, settleDeep(item)] as const);
if (!entries.some(([, item]) => isThenable(item))) {
return value;
}
return Promise.all(entries.map(([, item]) => item)).then(settled =>
Object.fromEntries(entries.map(([key], i) => [key, settled[i]])),
);
}
return value;
}
Loading
Loading