@formio/uag 2.0.0-rc.2 → 2.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,7 +55,7 @@ A person talks to Claude, ChatGPT or your own chat app, and the agent fills in a
55
55
 
56
56
  An application saves a submission through the UAG; an **Agent: Run task** action runs the form's task after the save and saves the result, which can start the next task. [Try it](https://ai.form.io/examples/workflow) · [source](./demo/server/workflow.mjs) · build it with [Workflows](https://ai.form.io/developers/workflows) · [prompt for your coding agent](https://github.com/formio/ai/blob/main/examples/agents/admissions-workflow.md).
57
57
 
58
- ![The admissions rubric and the Run task actions in the portal; an applicant submits; the admissions agent scores the application and its save starts the finance agent, which picks the award.](./demo/public/images/demo-workflow.gif)
58
+ ![The admissions criteria and the Run task actions in the portal; an applicant submits; the admissions agent scores the application and its save starts the finance agent, which picks the award.](./demo/public/images/demo-workflow.gif)
59
59
 
60
60
  ### Agentic Formflow
61
61
 
@@ -323,7 +323,7 @@ The full table, with the Agent API's errors: [Troubleshooting](https://ai.form.i
323
323
  | [`@formio/uag-sdk`](./packages/engine-sdk) | 2.x | ≥ 2.0.0 | Engine contract version 1, remote engine protocol version 1. |
324
324
  | [`@formio/uag-engines`](./packages/engine-server), image `formio/uag-engines` | 2.x | ≥ 2.0.0 | The engine server. |
325
325
  | [`@formio/uag-js`](./packages/uag-js) | 2.x | ≥ 2.0.0 | The formio.js module: saves `uag` forms through the UAG, adds Agent Input, and renders forms as a chat. Runs in the browser. |
326
- | `@formio/uag-claude` | 1.3.x | ≥ 1.12.0 | The retired Claude integration. Security fixes only; see [MIGRATING-2.0.md](./MIGRATING-2.0.md). |
326
+ | `@formio/uag-claude` | 1.3.x | 1.12.0 – 1.13.x | The retired Claude integration (against 2.0, only its direct commands). Security fixes only; see [MIGRATING-2.0.md](./MIGRATING-2.0.md). |
327
327
 
328
328
  `@formio/uag-claude` 2.x is the Claude *engine*, not the 1.x integration; pin `@formio/uag-claude@^1` if you still install the integration. Upgrading from 1.x: [MIGRATING-2.0.md](./MIGRATING-2.0.md).
329
329
 
package/bin/uag-eval.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * [--engine jev] [--json report.json]
7
7
  *
8
8
  * --form A Form.io form definition (JSON export).
9
- * --task The task to evaluate (default: the form's first). `--persona` is the old name.
9
+ * --task The task to evaluate (default: the form's first).
10
10
  * --examples A JSON array of { "id", "data": { ... }, "expected": { "<path>": <value or null> } }.
11
11
  * null means "the input does not say", which expects the field to stay empty.
12
12
  * --engine The decision engine to evaluate: an instance named in UAG_ENGINES, or an
@@ -25,7 +25,7 @@ require('dotenv/config');
25
25
  const { readFileSync, writeFileSync } = require('fs');
26
26
  const { parseInstances } = require('@formio/uag-sdk');
27
27
  const { ProjectInterface, UAGProjectInterface, UAGFormInterface, loadUAGEngines } = require('../lib');
28
- const { FormflowService, formatReport, resolveTaskName } = require('../lib/formflow');
28
+ const { FormflowService, formatReport } = require('../lib/formflow');
29
29
 
30
30
  /**
31
31
  * The instance to evaluate: the one UAG_ENGINES names `name`, so a deployed
@@ -51,7 +51,11 @@ async function main() {
51
51
  console.error('Usage: uag-eval --form <form.json> --examples <examples.json> [--task <name>] [--engine <name>] [--json <out.json>] [--project <url>]');
52
52
  process.exit(2);
53
53
  }
54
- const task = resolveTaskName(opts, 'uag-eval');
54
+ if (opts.persona !== undefined) {
55
+ console.error('uag-eval: --persona was renamed --task in UAG 2.0.');
56
+ process.exit(2);
57
+ }
58
+ const task = opts.task || undefined;
55
59
  const formDef = JSON.parse(readFileSync(opts.form, 'utf8'));
56
60
  const examples = JSON.parse(readFileSync(opts.examples, 'utf8'));
57
61
  const entry = engineEntry(opts.engine, process.env);
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UAGFormInterface = void 0;
4
4
  const appserver_1 = require("@formio/appserver");
5
+ const formflow_1 = require("./formflow");
5
6
  const core_1 = require("@formio/core");
6
7
  const lodash_1 = require("lodash");
7
8
  const node_html_markdown_1 = require("node-html-markdown");
@@ -11,6 +12,22 @@ const CHAT_UNSUPPORTED = new Set([
11
12
  'datagrid', 'editgrid', 'datatable', 'dynamicWizard', 'tagpad', 'form', 'datamap', 'address',
12
13
  'file', 'signature', 'sketchpad', 'datasource', 'password', 'captcha'
13
14
  ]);
15
+ /**
16
+ * The components inside a layout or container flagged with `uag`, which makes
17
+ * each input inside it one of the task's fields. Panels, fieldsets, wells, tabs
18
+ * and containers hold theirs in `components`; columns in each column's
19
+ * `components`; tables in each cell's. Null for a component that holds none.
20
+ */
21
+ function layoutChildren(component) {
22
+ const comp = component;
23
+ if (Array.isArray(comp.components))
24
+ return comp.components;
25
+ if (Array.isArray(comp.columns))
26
+ return comp.columns.flatMap((column) => column?.components || []);
27
+ if (Array.isArray(comp.rows))
28
+ return comp.rows.flat().flatMap((cell) => cell?.components || []);
29
+ return null;
30
+ }
14
31
  class UAGFormInterface extends appserver_1.FormInterface {
15
32
  /**
16
33
  * The form's routes, with its agent operations (`agent/extract`, `agent/decide`,
@@ -67,8 +84,9 @@ class UAGFormInterface extends appserver_1.FormInterface {
67
84
  }
68
85
  }
69
86
  else {
70
- if (component.components) {
71
- core_1.Utils.eachComponent(component.components, (subComp, subPath) => {
87
+ const children = layoutChildren(component);
88
+ if (children) {
89
+ core_1.Utils.eachComponent(children, (subComp, subPath) => {
72
90
  subPath = paths?.dataPath ? `${paths.dataPath}.${subPath}` : subPath;
73
91
  const compInfo = this.getComponentInfo(subComp, subPath);
74
92
  compInfo.rule = this.getComponentValueRule(subComp);
@@ -332,12 +350,10 @@ class UAGFormInterface extends appserver_1.FormInterface {
332
350
  * Override the process methods for the UAG.
333
351
  */
334
352
  async process(submission, auth, headers = {}, findQuery = {}, additional = []) {
335
- const processors = [...core_1.Processors, ...additional];
336
- // Remove the 'fetch' processor since UAG handles this with the fetch_external_data tool.
337
- const fetchIndex = processors.findIndex((p) => p.name === 'fetch');
338
- if (fetchIndex !== -1) {
339
- processors.splice(fetchIndex, 1);
340
- }
353
+ // Leave out 'fetch', since the UAG handles it with the fetch_external_data tool, and
354
+ // 'dereference', which expands a "Save as reference" select or a Data Table from the
355
+ // database the UAG does not have: it would throw for every form with one.
356
+ const processors = [...core_1.Processors, ...additional].filter((p) => p.name !== 'fetch' && p.name !== 'dereference');
341
357
  // Return the processed submission data.
342
358
  const context = await super.process(submission, auth, headers, findQuery, processors);
343
359
  // Filter the "required" validation since the UAG retrieves required fields separately.
@@ -519,7 +535,7 @@ class UAGFormInterface extends appserver_1.FormInterface {
519
535
  return;
520
536
  if (component.calculateValue && !component.allowCalculateOverride)
521
537
  return;
522
- if (owned.has(path) || component.properties?.uagAuthoritative === 'true')
538
+ if (owned.has(path) || (0, formflow_1.flagOn)(component.properties?.uagAuthoritative))
523
539
  return;
524
540
  result.fields.push({
525
541
  ...this.getComponentInfo(component, path),
@@ -164,10 +164,16 @@ class UAGProjectInterface extends appserver_1.ProjectInterface {
164
164
  // Point MCP clients at OAuth sign-in only when it is on (MCP_OAUTH, with the database).
165
165
  const pkce = this.provider('auth.pkce');
166
166
  const metadata = pkce?.oauthReady ? `, resource_metadata="${pkce.publicUrl(req)}/.well-known/oauth-protected-resource"` : '';
167
+ // RFC 6750 3.1: a request that presented a token is told it is invalid; one
168
+ // that presented none gets no error code, only the challenge.
169
+ const presented = !!(0, formflow_1.bearerOf)(req.get('authorization'));
170
+ const description = String(err?.message || err || 'Unauthorized').replace(/["\\]/g, '');
171
+ const challenge = presented ? `, error="invalid_token", error_description="${description}"` : '';
167
172
  return res.status(401).set({
168
- 'WWW-Authenticate': `Bearer realm="uag-server"${metadata}`
173
+ 'WWW-Authenticate': `Bearer realm="uag-server"${challenge}${metadata}`
169
174
  }).json({
170
- detail: err.message || err
175
+ error: presented ? 'invalid_token' : 'unauthorized',
176
+ error_description: presented ? description : 'Send an access token: Authorization: Bearer <token>.'
171
177
  });
172
178
  }
173
179
  }
package/lib/actionLogs.js CHANGED
@@ -35,12 +35,14 @@ function actionItem(record) {
35
35
  if (record.finished) {
36
36
  const written = record.written || [];
37
37
  const cleared = record.cleared || [];
38
- const info = record.status === 'failed'
38
+ const refused = record.refused || [];
39
+ const left = refused.length ? ` Left out answers the form refused: ${refused.join(', ')}.` : '';
40
+ const info = (record.status === 'failed'
39
41
  ? `Failed: ${record.error || 'unknown error'}`
40
42
  : record.saved
41
43
  ? `Saved ${written.length} field(s)${written.length ? `: ${written.join(', ')}` : ''}${cleared.length ? `; cleared ${cleared.join(', ')}` : ''}.`
42
- : 'Nothing changed, so nothing was saved.';
43
- messages.push({ datetime: record.finished.toISOString(), info, data: { engines: record.engines || [], written, cleared } });
44
+ : 'Nothing changed, so nothing was saved.') + left;
45
+ messages.push({ datetime: record.finished.toISOString(), info, data: { engines: record.engines || [], written, cleared, ...(refused.length && { refused }) } });
44
46
  }
45
47
  return {
46
48
  _id: String(record._id),
@@ -9,6 +9,7 @@ const formflow_1 = require("../formflow");
9
9
  const runner_1 = require("../runner");
10
10
  const protocol_1 = require("@formio/uag-sdk/protocol");
11
11
  const requestId_1 = require("../agent/requestId");
12
+ const errors_1 = require("../agent/errors");
12
13
  const identity_1 = require("../identity");
13
14
  const debug = require('debug')('formio:uag:actions:runTask');
14
15
  exports.RUN_TASK = 'agentRunTask';
@@ -89,12 +90,12 @@ function onBehalfHook() {
89
90
  if (roles.length > 1) {
90
91
  console.error(`[formio-uag] The Run task actions of "${form.name}" that run on create name different agent roles ` +
91
92
  `(${actions.map((action) => action.title || action.machineName).join(', ')}). Choose one agent role for them all.`);
92
- return res.status(500).json({ name: 'ConfigurationError', message: `The form "${form.name}" is not configured correctly: its Run task actions name different agent roles.` });
93
+ return res.status(500).json({ name: 'ConfigurationError', message: `The form "${form.name}" is not configured correctly: its Run task actions name different agent roles.`, code: 'configuration_error' });
93
94
  }
94
95
  const [role] = roles;
95
96
  const submitters = actions.flatMap((action) => action.settings.submitterRoles || []).map(String);
96
97
  if (!appserver_1.AuthRequest.hasRole([role], access[appserver_1.AuthorizedStateType.ALL], project) || !has(submitters)) {
97
- return res.status(403).json({ name: 'ForbiddenError', message: 'You may not submit this form.' });
98
+ return res.status(403).json({ name: 'ForbiddenError', message: 'You may not submit this form.', code: 'forbidden' });
98
99
  }
99
100
  try {
100
101
  req.forwardAuth = await (0, identity_1.agentAuth)(project, actingFor, role, { task: actions[0].settings.task });
@@ -127,7 +128,7 @@ function refuse(res, err) {
127
128
  res.status(err.status).json({
128
129
  name: err.name,
129
130
  message: err.message,
130
- ...(err.code && { code: err.code }),
131
+ code: err.code || (0, errors_1.errorCode)(err.status),
131
132
  ...(err.edition && { edition: err.edition })
132
133
  });
133
134
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AnonymousLimiter = void 0;
4
4
  exports.anonymousLimits = anonymousLimits;
5
5
  exports.anonymousGate = anonymousGate;
6
+ const formflow_1 = require("../formflow");
6
7
  const debug = require('debug')('formio:uag:agent');
7
8
  const WINDOW_MS = 60000;
8
9
  /** A number from the configuration, else the environment, else the default. */
@@ -74,14 +75,14 @@ function anonymousGate(project) {
74
75
  const request = req;
75
76
  const form = request.form;
76
77
  const limits = anonymousLimits(project);
77
- if (limits.perAddress === 0 || limits.total === 0 || String(form?.form?.properties?.uagAnonymous) !== 'true') {
78
- return res.status(401).json({ detail: 'Sign in to use this form\'s agent operations.' });
78
+ if (limits.perAddress === 0 || limits.total === 0 || !(0, formflow_1.flagOn)(form?.form?.properties?.uagAnonymous)) {
79
+ return res.status(401).json({ error: 'Sign in to use this form\'s agent operations.', code: 'sign_in_required' });
79
80
  }
80
81
  const taken = limiterFor(project).take(String(req.ip || req.socket?.remoteAddress || ''), limits);
81
82
  if (!taken.allowed) {
82
83
  debug(`Refused an anonymous ${req.method} ${req.path}: over the anonymous rate limit.`);
83
84
  res.set('Retry-After', String(taken.retryAfter));
84
- return res.status(429).json({ detail: 'Too many requests. Try again shortly.' });
85
+ return res.status(429).json({ error: 'Too many requests. Try again shortly.', code: 'rate_limited' });
85
86
  }
86
87
  let permissions = {};
87
88
  try {
@@ -91,7 +92,7 @@ function anonymousGate(project) {
91
92
  permissions = {};
92
93
  }
93
94
  if (!permissions.create) {
94
- return res.status(403).json({ detail: 'Anonymous visitors may not submit this form.' });
95
+ return res.status(403).json({ error: 'Anonymous visitors may not submit this form.', code: 'forbidden' });
95
96
  }
96
97
  next();
97
98
  };
package/lib/agent/chat.js CHANGED
@@ -162,7 +162,9 @@ function fieldSpec(form, field) {
162
162
  label: field.label,
163
163
  type: field.type,
164
164
  description: [field.required ? 'Required.' : 'Optional.', field.description, field.prompt && `Placeholder: ${field.prompt}`].filter(Boolean).join(' '),
165
- rule: field.rule || '',
165
+ // The host's rule, then the component's validation (lengths, pattern, range, mask), so a
166
+ // first answer is likely to pass the form's checks.
167
+ rule: (0, formflow_1.ruleWithConstraints)(field.rule, component),
166
168
  };
167
169
  if (field.format)
168
170
  spec.format = field.format;
@@ -387,7 +389,7 @@ async function chatTurn(project, form, body, context) {
387
389
  const request = readChatRequest(body, settings);
388
390
  const engines = chatEngines(project, settings);
389
391
  if (!engines.length) {
390
- throw new formflow_1.FormflowError(503, 'No enabled engine can serve chat. Enable an agent engine with UAG_ENGINES.');
392
+ throw new formflow_1.FormflowError(503, 'No enabled engine can serve chat. Enable an agent engine with UAG_ENGINES.', 'no_engine');
391
393
  }
392
394
  const service = (0, formflow_1.formflow)(project);
393
395
  const before = await form.chatFields({ data: (0, lodash_1.cloneDeep)(request.data) }, context.auth);
@@ -8,7 +8,7 @@ exports.loadUAGEngines = loadUAGEngines;
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const uag_sdk_1 = require("@formio/uag-sdk");
10
10
  const loader_1 = require("@formio/uag-sdk/loader");
11
- const appserver_core_1 = require("@formio/appserver-core");
11
+ const appserver_1 = require("@formio/appserver");
12
12
  const remote_1 = require("./remote");
13
13
  const stubs_1 = require("./stubs");
14
14
  /** The UAG's own installation, where engine packages are looked up first. */
@@ -31,7 +31,7 @@ async function loadUAGEngines(config = {}, env = process.env) {
31
31
  paths: (0, loader_1.enginePaths)(exports.UAG_ROOT, env),
32
32
  env,
33
33
  config,
34
- apiVersion: appserver_core_1.Formflow.ENGINE_API_VERSION,
34
+ apiVersion: appserver_1.Formflow.ENGINE_API_VERSION,
35
35
  });
36
- return appserver_core_1.Formflow.engineRegistry(loaded);
36
+ return appserver_1.Formflow.engineRegistry(loaded);
37
37
  }
@@ -0,0 +1,16 @@
1
+ import { Request, RequestHandler, Response } from "express";
2
+ export declare function isAgentApiRequest(req: Request): boolean;
3
+ /** The code for a status, when the error names none. */
4
+ export declare function errorCode(status: number): string;
5
+ export declare const INTERNAL_ERROR = "The request failed. See the UAG logs for this request id.";
6
+ /** The request's id, assigned once: the caller's, when it is a short token, or a new one. */
7
+ export declare function requestIdOf(req: Request, res: Response): string;
8
+ /** The error body for a status and whatever a handler sent: an object, JSON text, plain text or nothing. */
9
+ export declare function errorBody(requestId: string, status: number, sent: unknown): Record<string, any>;
10
+ /**
11
+ * Mounted ahead of every route: for an Agent API request, assigns the request
12
+ * id and rewrites any error response into the one shape, so refusals from
13
+ * middleware the UAG does not own (the project's authentication answers plain
14
+ * text, the portal's team access an empty 401) look like every other.
15
+ */
16
+ export declare function agentErrorShape(): RequestHandler;
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INTERNAL_ERROR = void 0;
4
+ exports.isAgentApiRequest = isAgentApiRequest;
5
+ exports.errorCode = errorCode;
6
+ exports.requestIdOf = requestIdOf;
7
+ exports.errorBody = errorBody;
8
+ exports.agentErrorShape = agentErrorShape;
9
+ const crypto_1 = require("crypto");
10
+ const protocol_1 = require("@formio/uag-sdk/protocol");
11
+ /**
12
+ * The Agent API's one error shape. Every error a form's agent operation or
13
+ * `/agent/capabilities` answers is JSON
14
+ *
15
+ * { "requestId": "...", "error": "a message for people", "code": "a_stable_code", ... }
16
+ *
17
+ * with the same id in `X-Request-Id`, whatever refused it: the UAG, the
18
+ * project's authentication, the portal's team access, the body parser.
19
+ * Clients branch on `code`; `error` is for people. A 500 never carries details:
20
+ * those are in the UAG's log under the request id.
21
+ */
22
+ /** A form's agent operations, and the capabilities. */
23
+ const OPERATION = /\/agent\/(extract|decide|generate|chat)\/?$/i;
24
+ const CAPABILITIES = /^\/agent\/capabilities\/?$/i;
25
+ function isAgentApiRequest(req) {
26
+ return (req.method === 'POST' && OPERATION.test(req.path)) || (req.method === 'GET' && CAPABILITIES.test(req.path));
27
+ }
28
+ /** The code for a status, when the error names none. */
29
+ function errorCode(status) {
30
+ switch (status) {
31
+ case 400: return 'invalid_request';
32
+ case 401: return 'unauthorized';
33
+ case 403: return 'forbidden';
34
+ case 404: return 'not_found';
35
+ case 409: return 'conflict';
36
+ case 413: return 'payload_too_large';
37
+ case 429: return 'rate_limited';
38
+ case 502: return 'engines_failed';
39
+ case 503: return 'unavailable';
40
+ case 504: return 'timeout';
41
+ default: return status >= 500 ? 'internal_error' : 'invalid_request';
42
+ }
43
+ }
44
+ exports.INTERNAL_ERROR = 'The request failed. See the UAG logs for this request id.';
45
+ /** The request's id, assigned once: the caller's, when it is a short token, or a new one. */
46
+ function requestIdOf(req, res) {
47
+ const request = req;
48
+ if (!request.requestId) {
49
+ request.requestId = (0, protocol_1.safeRequestId)(req.get('x-request-id')) || (0, crypto_1.randomUUID)();
50
+ }
51
+ if (!res.headersSent)
52
+ res.set('X-Request-Id', request.requestId);
53
+ return request.requestId;
54
+ }
55
+ /** The error body for a status and whatever a handler sent: an object, JSON text, plain text or nothing. */
56
+ function errorBody(requestId, status, sent) {
57
+ let value = sent;
58
+ if (Buffer.isBuffer(value))
59
+ value = value.toString('utf8');
60
+ if (typeof value === 'string') {
61
+ try {
62
+ value = JSON.parse(value);
63
+ }
64
+ catch {
65
+ value = { error: value };
66
+ }
67
+ }
68
+ const given = value && typeof value === 'object' ? value : {};
69
+ const text = [given.error, given.detail, given.message].find((part) => typeof part === 'string' && part.trim());
70
+ // A 500's own message may carry internals: only the UAG's fixed sentence goes out.
71
+ const error = status >= 500 && status !== 502 && status !== 503 && status !== 504 ? exports.INTERNAL_ERROR : (text || defaultMessage(status));
72
+ return {
73
+ requestId,
74
+ error,
75
+ code: typeof given.code === 'string' && given.code ? given.code : errorCode(status),
76
+ ...(typeof given.edition === 'string' && { edition: given.edition })
77
+ };
78
+ }
79
+ function defaultMessage(status) {
80
+ switch (status) {
81
+ case 401: return 'Unauthorized.';
82
+ case 403: return 'Forbidden.';
83
+ case 404: return 'Not found.';
84
+ case 413: return 'The request body is too large.';
85
+ case 429: return 'Too many requests. Try again shortly.';
86
+ default: return 'The request could not be completed.';
87
+ }
88
+ }
89
+ /**
90
+ * Mounted ahead of every route: for an Agent API request, assigns the request
91
+ * id and rewrites any error response into the one shape, so refusals from
92
+ * middleware the UAG does not own (the project's authentication answers plain
93
+ * text, the portal's team access an empty 401) look like every other.
94
+ */
95
+ function agentErrorShape() {
96
+ return (req, res, next) => {
97
+ if (!isAgentApiRequest(req))
98
+ return next();
99
+ const requestId = requestIdOf(req, res);
100
+ const send = res.send.bind(res);
101
+ let shaped = false;
102
+ res.send = ((body) => {
103
+ // An event stream writes its own error events; only whole responses are shaped.
104
+ if (shaped || res.statusCode < 400)
105
+ return send(body);
106
+ shaped = true;
107
+ res.type('application/json');
108
+ return send(JSON.stringify(errorBody(requestId, res.statusCode, body)));
109
+ });
110
+ next();
111
+ };
112
+ }
@@ -38,10 +38,9 @@ exports.AgentRouter = AgentRouter;
38
38
  exports.agentNotFound = agentNotFound;
39
39
  exports.agentCredentials = agentCredentials;
40
40
  exports.FormAgentRouter = FormAgentRouter;
41
- const crypto_1 = require("crypto");
42
41
  const lodash_1 = require("lodash");
43
42
  const express_1 = __importStar(require("express"));
44
- const protocol_1 = require("@formio/uag-sdk/protocol");
43
+ const errors_1 = require("./errors");
45
44
  const formflow_1 = require("../formflow");
46
45
  const requestId_1 = require("./requestId");
47
46
  const anonymous_1 = require("./anonymous");
@@ -67,10 +66,17 @@ function openStream(res) {
67
66
  function contextOf(req) {
68
67
  return { auth: req.auth, requestId: req.requestId, identity: (0, formflow_1.callerIdentity)(req.auth, req) };
69
68
  }
70
- /** The task the request names, if any. */
69
+ /**
70
+ * The task the request names, if any. A request that still names a 1.x
71
+ * `persona` is refused: ignoring it would run every task, an engine call each.
72
+ */
71
73
  function taskOf(req) {
74
+ const named = (value) => typeof value === 'string' && value.trim() !== '';
72
75
  const task = req.body?.task;
73
- return typeof task === 'string' && task ? task : undefined;
76
+ if (named(req.body?.persona) && !named(task)) {
77
+ throw new formflow_1.FormflowError(400, 'UAG 2.0 renamed "persona" to "task". Send "task" instead.');
78
+ }
79
+ return named(task) ? task : undefined;
74
80
  }
75
81
  /**
76
82
  * Answer extract, streaming per-engine results when the client asks for a
@@ -113,15 +119,15 @@ function agentEndpoints(project) {
113
119
  return parse(req, res, next);
114
120
  const length = Number(req.get('content-length')) || Buffer.byteLength(JSON.stringify(req.body ?? null));
115
121
  if (length > exports.AGENT_BODY_LIMIT) {
116
- return res.status(413).json({ error: `The request body is larger than the agent operations' limit of ${exports.AGENT_BODY_LIMIT / (1024 * 1024)} MB.` });
122
+ return res.status(413).json({ error: `The request body is larger than the agent operations' limit of ${exports.AGENT_BODY_LIMIT / (1024 * 1024)} MB.`, code: 'payload_too_large' });
117
123
  }
118
124
  next();
119
125
  };
120
- // Request id, after authentication: refused requests carry none. One that
121
- // is not a short token is replaced, so it is never echoed, logged or sent on.
126
+ // The request id, usually assigned already by agentErrorShape, ahead of every
127
+ // refusal. One that is not a short token is replaced, so it is never echoed,
128
+ // logged or sent on.
122
129
  const requestId = (req, res, next) => {
123
- req.requestId = (0, protocol_1.safeRequestId)(req.get('x-request-id')) || (0, crypto_1.randomUUID)();
124
- res.set('X-Request-Id', req.requestId);
130
+ (0, errors_1.requestIdOf)(req, res);
125
131
  next();
126
132
  };
127
133
  const handle = (operation, fn) => async (req, res) => {
@@ -142,8 +148,8 @@ function agentEndpoints(project) {
142
148
  }
143
149
  const body = {
144
150
  requestId: agentReq.requestId,
145
- error: status === 500 ? 'The request failed. See the UAG logs for this request id.' : err.message,
146
- ...(err instanceof formflow_1.FormflowError && err.code && { code: err.code }),
151
+ error: status === 500 ? errors_1.INTERNAL_ERROR : err.message,
152
+ code: (err instanceof formflow_1.FormflowError && err.code) || (0, errors_1.errorCode)(status),
147
153
  ...(err?.edition && { edition: err.edition })
148
154
  };
149
155
  if (res.headersSent) {
@@ -207,11 +213,11 @@ function agentNotFound(project) {
207
213
  : !!formPath && forms.some((form) => String(form.path || '').toLowerCase() === formPath.toLowerCase());
208
214
  if (known)
209
215
  return next();
210
- res.status(404).json({ error: `The form "${byId ? byId[1] : formPath}" does not exist or is not tagged "uag".` });
216
+ res.status(404).json({ error: `The form "${byId ? byId[1] : formPath}" does not exist or is not tagged "uag".`, code: 'form_not_found' });
211
217
  };
212
218
  }
213
- function refuse(res, detail) {
214
- return res.status(401).json({ detail });
219
+ function refuse(res, error) {
220
+ return res.status(401).json({ error, code: 'unauthorized' });
215
221
  }
216
222
  /**
217
223
  * Who may call a form's agent operations, on top of the project's own
@@ -283,10 +289,10 @@ function FormAgentRouter(project, credentials) {
283
289
  route('chat', 'chat', async (req, res, service) => {
284
290
  const form = await project.formflowForm(formOf(req));
285
291
  if (!form) {
286
- throw new formflow_1.FormflowError(404, `The form "${formOf(req)}" does not exist or is not tagged "uag".`);
292
+ throw new formflow_1.FormflowError(404, `The form "${formOf(req)}" does not exist or is not tagged "uag".`, 'form_not_found');
287
293
  }
288
- if (String(form.form?.properties?.uagChat) === 'false') {
289
- throw new formflow_1.FormflowError(403, `Chat is turned off for the form "${form.formflowName}".`);
294
+ if ((0, formflow_1.flagOff)(form.form?.properties?.uagChat)) {
295
+ throw new formflow_1.FormflowError(403, `Chat is turned off for the form "${form.formflowName}".`, 'chat_disabled');
290
296
  }
291
297
  service.requireAccess(req.auth, form, ['create']);
292
298
  res.json(await (0, chat_1.chatTurn)(project, form, req.body, { auth: req.auth, requestId: req.requestId }));
package/lib/config.d.ts CHANGED
@@ -47,8 +47,6 @@ export type UAGToolOverride = {
47
47
  submission_update?: ToolInfo;
48
48
  find_submissions?: ToolInfo;
49
49
  agent_provide?: ToolInfo;
50
- /** @deprecated The earlier name of `agent_provide`; also applied to it when `agent_provide` is not set. */
51
- agent_provide_data?: ToolInfo;
52
50
  agent_decide?: ToolInfo;
53
51
  fetch_external_data?: ToolInfo;
54
52
  };
package/lib/database.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Collection, Db, MongoClient, MongoClientOptions } from 'mongodb';
2
- import type { AuthCodeStore } from '@formio/appserver-core';
2
+ import type { AuthCodeStore } from '@formio/appserver';
3
3
  /**
4
4
  * The UAG's own database (MONGO, with the database named by UAG_DATABASE). It
5
5
  * holds only the UAG's state: authorization codes for remote MCP clients, the
package/lib/formflow.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { Formflow } from '@formio/appserver-core';
1
+ import { Formflow } from '@formio/appserver';
2
2
  /**
3
- * Agentic Formflow from @formio/appserver-core, imported directly rather than
4
- * through @formio/appserver, under the names the UAG uses. Internal to the UAG:
3
+ * Agentic Formflow from @formio/appserver-core, through @formio/appserver (see
4
+ * assertSingleCore), under the names the UAG uses. Internal to the UAG:
5
5
  * applications reach Formflow as `Formflow` from this package's exports.
6
6
  */
7
- export declare const EngineConfigError: typeof Formflow.EngineConfigError, EngineRegistry: typeof Formflow.EngineRegistry, FormflowError: typeof Formflow.FormflowError, FormflowService: typeof Formflow.FormflowService, RateLimitError: typeof Formflow.RateLimitError, ScopedTokenError: typeof Formflow.ScopedTokenError, bearerOf: typeof Formflow.bearerOf, engineRegistry: typeof Formflow.engineRegistry, formflow: typeof Formflow.formflow, formflowAuthenticate: typeof Formflow.formflowAuthenticate, identityOf: typeof Formflow.identityOf, issueScopedToken: typeof Formflow.issueScopedToken, isScopedToken: typeof Formflow.isScopedToken, readScopedToken: typeof Formflow.readScopedToken, resolveTaskName: typeof Formflow.resolveTaskName, resultEvent: typeof Formflow.resultEvent, warnLegacy: typeof Formflow.warnLegacy, withoutOwnedValues: typeof Formflow.withoutOwnedValues, stubAgentEngine: typeof Formflow.stubAgentEngine, stubDecisionEngine: typeof Formflow.stubDecisionEngine, toolsForScope: typeof Formflow.toolsForScope, EngineError: typeof Formflow.EngineError, formatReport: typeof Formflow.formatReport;
7
+ export declare const EngineConfigError: typeof Formflow.EngineConfigError, EngineRegistry: typeof Formflow.EngineRegistry, FormflowError: typeof Formflow.FormflowError, FormflowService: typeof Formflow.FormflowService, RateLimitError: typeof Formflow.RateLimitError, ScopedTokenError: typeof Formflow.ScopedTokenError, bearerOf: typeof Formflow.bearerOf, engineRegistry: typeof Formflow.engineRegistry, formflow: typeof Formflow.formflow, formflowAuthenticate: typeof Formflow.formflowAuthenticate, identityOf: typeof Formflow.identityOf, issueScopedToken: typeof Formflow.issueScopedToken, isScopedToken: typeof Formflow.isScopedToken, readScopedToken: typeof Formflow.readScopedToken, resultEvent: typeof Formflow.resultEvent, withoutOwnedValues: typeof Formflow.withoutOwnedValues, stubAgentEngine: typeof Formflow.stubAgentEngine, stubDecisionEngine: typeof Formflow.stubDecisionEngine, toolsForScope: typeof Formflow.toolsForScope, EngineError: typeof Formflow.EngineError, formatReport: typeof Formflow.formatReport, flagOn: typeof Formflow.flagOn, flagOff: typeof Formflow.flagOff, ruleWithConstraints: typeof Formflow.ruleWithConstraints;
8
8
  export type AgentEngine = Formflow.AgentEngine;
9
9
  export type CallerIdentity = Formflow.CallerIdentity;
10
10
  export type DecisionEngine = Formflow.DecisionEngine;
package/lib/formflow.js CHANGED
@@ -1,14 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.formatReport = exports.EngineError = exports.toolsForScope = exports.stubDecisionEngine = exports.stubAgentEngine = exports.withoutOwnedValues = exports.warnLegacy = exports.resultEvent = exports.resolveTaskName = exports.readScopedToken = exports.isScopedToken = exports.issueScopedToken = exports.identityOf = exports.formflowAuthenticate = exports.formflow = exports.engineRegistry = exports.bearerOf = exports.ScopedTokenError = exports.RateLimitError = exports.FormflowService = exports.FormflowError = exports.EngineRegistry = exports.EngineConfigError = void 0;
3
+ exports.ruleWithConstraints = exports.flagOff = exports.flagOn = exports.formatReport = exports.EngineError = exports.toolsForScope = exports.stubDecisionEngine = exports.stubAgentEngine = exports.withoutOwnedValues = exports.resultEvent = exports.readScopedToken = exports.isScopedToken = exports.issueScopedToken = exports.identityOf = exports.formflowAuthenticate = exports.formflow = exports.engineRegistry = exports.bearerOf = exports.ScopedTokenError = exports.RateLimitError = exports.FormflowService = exports.FormflowError = exports.EngineRegistry = exports.EngineConfigError = void 0;
4
4
  exports.callerIdentity = callerIdentity;
5
- const appserver_core_1 = require("@formio/appserver-core");
5
+ const appserver_1 = require("@formio/appserver");
6
6
  /**
7
- * Agentic Formflow from @formio/appserver-core, imported directly rather than
8
- * through @formio/appserver, under the names the UAG uses. Internal to the UAG:
7
+ * Agentic Formflow from @formio/appserver-core, through @formio/appserver (see
8
+ * assertSingleCore), under the names the UAG uses. Internal to the UAG:
9
9
  * applications reach Formflow as `Formflow` from this package's exports.
10
10
  */
11
- ({ EngineConfigError: exports.EngineConfigError, EngineRegistry: exports.EngineRegistry, FormflowError: exports.FormflowError, FormflowService: exports.FormflowService, RateLimitError: exports.RateLimitError, ScopedTokenError: exports.ScopedTokenError, bearerOf: exports.bearerOf, engineRegistry: exports.engineRegistry, formflow: exports.formflow, formflowAuthenticate: exports.formflowAuthenticate, identityOf: exports.identityOf, issueScopedToken: exports.issueScopedToken, isScopedToken: exports.isScopedToken, readScopedToken: exports.readScopedToken, resolveTaskName: exports.resolveTaskName, resultEvent: exports.resultEvent, warnLegacy: exports.warnLegacy, withoutOwnedValues: exports.withoutOwnedValues, stubAgentEngine: exports.stubAgentEngine, stubDecisionEngine: exports.stubDecisionEngine, toolsForScope: exports.toolsForScope, EngineError: exports.EngineError, formatReport: exports.formatReport } = appserver_core_1.Formflow);
11
+ ({ EngineConfigError: exports.EngineConfigError, EngineRegistry: exports.EngineRegistry, FormflowError: exports.FormflowError, FormflowService: exports.FormflowService, RateLimitError: exports.RateLimitError, ScopedTokenError: exports.ScopedTokenError, bearerOf: exports.bearerOf, engineRegistry: exports.engineRegistry, formflow: exports.formflow, formflowAuthenticate: exports.formflowAuthenticate, identityOf: exports.identityOf, issueScopedToken: exports.issueScopedToken, isScopedToken: exports.isScopedToken, readScopedToken: exports.readScopedToken, resultEvent: exports.resultEvent, withoutOwnedValues: exports.withoutOwnedValues, stubAgentEngine: exports.stubAgentEngine, stubDecisionEngine: exports.stubDecisionEngine, toolsForScope: exports.toolsForScope, EngineError: exports.EngineError, formatReport: exports.formatReport, flagOn: exports.flagOn, flagOff: exports.flagOff, ruleWithConstraints: exports.ruleWithConstraints } = appserver_1.Formflow);
12
12
  /**
13
13
  * Who an operation's research acts as: core's `identityOf`, and for a caller
14
14
  * signed in with a Form.io token (`x-jwt-token`), that token, so a scoped token
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Server, ServerModule } from '@formio/appserver';
2
- import { Router } from 'express';
2
+ import { Request, Response, Router } from 'express';
3
3
  import { UAGProjectInterface } from './UAGProjectInterface';
4
4
  import { UAGFormInterface } from './UAGFormInterface';
5
5
  import { UAGConfig } from './config';
@@ -8,12 +8,19 @@ export type UAGModule = ServerModule & {
8
8
  config?: UAGConfig;
9
9
  };
10
10
  /**
11
- * Agentic Formflow keeps its license state inside @formio/appserver-core, so the
12
- * UAG and @formio/appserver must load the same copy of it, at 2.24.1 or later.
13
- * A second copy (an older lockfile, or a package manager that nests it) would
14
- * otherwise fail in confusing ways; say what to do instead.
11
+ * Agentic Formflow keeps its license state inside @formio/appserver-core. The UAG
12
+ * loads it only through @formio/appserver, so the two always share one copy,
13
+ * which must be 2.25.1 or later. An older @formio/appserver, or an old lockfile,
14
+ * can still resolve an older one, which would fail in confusing ways; say what
15
+ * to do instead.
15
16
  */
16
- export declare function assertSingleCore(core?: any, appserver?: any, version?: string): void;
17
+ export declare function assertSingleCore(version?: string): void;
18
+ /**
19
+ * isolated-vm, which runs form logic, needs Node's startup snapshot off. Without
20
+ * it nothing fails at once: the process can crash later, in native code. So an
21
+ * embedding server that forgot the flag is stopped here, saying what to do.
22
+ */
23
+ export declare function assertNodeSnapshotDisabled(execArgv?: string[], nodeOptions?: string): void;
17
24
  export declare class UAGServer extends Server {
18
25
  config: UAGConfig;
19
26
  /** The UAG's own database, once it may be used. */
@@ -36,6 +43,12 @@ export declare class UAGServer extends Server {
36
43
  * otherwise leave it untouched, close it, and say why.
37
44
  */
38
45
  protected enableDatabase(project: UAGProjectInterface): Promise<void>;
46
+ /**
47
+ * An Agent API request that failed before its handlers (a body that is not JSON,
48
+ * one over the size limit) is answered in the Agent API's error shape; every other
49
+ * route keeps Form.io's plain-text answer.
50
+ */
51
+ protected sendError(req: Request, res: Response, status: number, message: string, err?: any): void;
39
52
  /** Browsers reading Agent API responses need the request id too. */
40
53
  get exposedHeaders(): string[];
41
54
  /** The loaded UAG project, once the project router has been built. */
package/lib/index.js CHANGED
@@ -13,11 +13,16 @@ var __createBinding = (this && this.__createBinding) || (Object.create ? (functi
13
13
  var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
16
19
  Object.defineProperty(exports, "__esModule", { value: true });
17
20
  exports.UAGFormInterface = exports.UAGProjectInterface = exports.UAGServer = void 0;
18
21
  exports.assertSingleCore = assertSingleCore;
22
+ exports.assertNodeSnapshotDisabled = assertNodeSnapshotDisabled;
19
23
  const appserver_1 = require("@formio/appserver");
20
24
  const formflow_1 = require("./formflow");
25
+ const path_1 = __importDefault(require("path"));
21
26
  const UAGProjectInterface_1 = require("./UAGProjectInterface");
22
27
  Object.defineProperty(exports, "UAGProjectInterface", { enumerable: true, get: function () { return UAGProjectInterface_1.UAGProjectInterface; } });
23
28
  const UAGFormInterface_1 = require("./UAGFormInterface");
@@ -25,6 +30,7 @@ Object.defineProperty(exports, "UAGFormInterface", { enumerable: true, get: func
25
30
  const lodash_1 = require("lodash");
26
31
  const engines_1 = require("./agent/engines");
27
32
  const router_1 = require("./agent/router");
33
+ const errors_1 = require("./agent/errors");
28
34
  const database_1 = require("./database");
29
35
  const runs_1 = require("./runs");
30
36
  const actionLogs_1 = require("./actionLogs");
@@ -32,19 +38,54 @@ const runner_1 = require("./runner");
32
38
  const runTask_1 = require("./actions/runTask");
33
39
  const debug = require('debug')('formio:uag');
34
40
  /**
35
- * Agentic Formflow keeps its license state inside @formio/appserver-core, so the
36
- * UAG and @formio/appserver must load the same copy of it, at 2.24.1 or later.
37
- * A second copy (an older lockfile, or a package manager that nests it) would
38
- * otherwise fail in confusing ways; say what to do instead.
41
+ * Agentic Formflow keeps its license state inside @formio/appserver-core. The UAG
42
+ * loads it only through @formio/appserver, so the two always share one copy,
43
+ * which must be 2.25.1 or later. An older @formio/appserver, or an old lockfile,
44
+ * can still resolve an older one, which would fail in confusing ways; say what
45
+ * to do instead.
39
46
  */
40
- function assertSingleCore(core = require('@formio/appserver-core'), appserver = require('@formio/appserver'), version = String(require('@formio/appserver-core/package.json').version || '0.0.0')) {
47
+ function assertSingleCore(version = appserverCoreVersion()) {
41
48
  const [major, minor, patch] = version.split('.').map((part) => parseInt(part, 10) || 0);
42
- if (major < 2 || (major === 2 && (minor < 24 || (minor === 24 && patch < 1)))) {
43
- throw new Error(`@formio/uag needs @formio/appserver-core 2.24.1 or later; ${version} is installed.`);
49
+ if (major < 2 || (major === 2 && (minor < 25 || (minor === 25 && patch < 1)))) {
50
+ throw new Error(`@formio/uag needs @formio/appserver-core 2.25.1 or later, through @formio/appserver; ${version} is installed. Upgrade @formio/appserver.`);
51
+ }
52
+ }
53
+ /** The version of the @formio/appserver-core that @formio/appserver loads. */
54
+ function appserverCoreVersion() {
55
+ const appserver = path_1.default.dirname(require.resolve('@formio/appserver/package.json'));
56
+ return String(require(require.resolve('@formio/appserver-core/package.json', { paths: [appserver] })).version || '0.0.0');
57
+ }
58
+ /**
59
+ * isolated-vm, which runs form logic, needs Node's startup snapshot off. Without
60
+ * it nothing fails at once: the process can crash later, in native code. So an
61
+ * embedding server that forgot the flag is stopped here, saying what to do.
62
+ */
63
+ function assertNodeSnapshotDisabled(execArgv = process.execArgv, nodeOptions = process.env.NODE_OPTIONS || '') {
64
+ if (execArgv.includes('--no-node-snapshot') || /(^|\s)--no-node-snapshot(\s|$)/.test(nodeOptions))
65
+ return;
66
+ throw new Error('@formio/uag needs Node started with --no-node-snapshot: isolated-vm, which runs form logic, can crash the process without it. ' +
67
+ 'Start node with --no-node-snapshot, or set NODE_OPTIONS=--no-node-snapshot. The uag command and the formio/uag image do this for you.');
68
+ }
69
+ /** A host name a browser cannot reach: a container or service name, with no dot. */
70
+ function internalHost(url) {
71
+ try {
72
+ const { hostname } = new URL(url);
73
+ return !!hostname && hostname !== 'localhost' && !hostname.includes('.') && !hostname.includes(':');
44
74
  }
45
- if (appserver.Formflow !== core.Formflow || appserver.ProjectInterface !== core.ProjectInterface) {
46
- throw new Error('Two copies of @formio/appserver-core are installed: one for @formio/uag and an older one for @formio/appserver. ' +
47
- 'Upgrade @formio/appserver to 2.24.1 or later and deduplicate (for example `yarn dedupe @formio/appserver-core`).');
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /** Warnings for settings that start fine and fail later, in ways far from their cause. */
80
+ function warnAboutSettings(config, secretGiven) {
81
+ if (!secretGiven) {
82
+ console.warn('[formio-uag] JWT_SECRET is not set, so the UAG signs its tokens with a random secret that lasts only as long as this process: ' +
83
+ 'every token stops working when it restarts, and other instances refuse it. Set JWT_SECRET, the same on every instance.');
84
+ }
85
+ const auth = config.auth || {};
86
+ if (auth.mcpOAuth && !auth.loginForm && internalHost(String(config.project || ''))) {
87
+ console.warn(`[formio-uag] MCP_OAUTH is on, but LOGIN_FORM is not set: the sign-in page loads ${String(config.project).replace(/\/+$/, '')}/user/login, ` +
88
+ 'an address a person\'s browser cannot reach. Set LOGIN_FORM to the login form\'s public URL.');
48
89
  }
49
90
  }
50
91
  /** The UAG's own database from the environment, when `MONGO` is set. */
@@ -62,8 +103,11 @@ function databaseFromEnv(env = process.env) {
62
103
  }
63
104
  class UAGServer extends appserver_1.Server {
64
105
  constructor(config) {
106
+ assertNodeSnapshotDisabled();
65
107
  assertSingleCore();
66
108
  const configuredDb = !!config?.db;
109
+ // Read before the server fills in a random one.
110
+ const secretGiven = !!(config?.auth?.secret || (0, lodash_1.get)(process.env, 'JWT_SECRET', '').toString());
67
111
  const uagLicense = (0, lodash_1.get)(process.env, 'UAG_LICENSE', '').toString();
68
112
  if (uagLicense) {
69
113
  console.warn('[formio-uag] UAG_LICENSE is deprecated. Put a Form.io license that includes the UAG in LICENSE_KEY instead; UAG_LICENSE keeps working until UAG 3.0.');
@@ -85,6 +129,8 @@ class UAGServer extends appserver_1.Server {
85
129
  retryDelay: parseInt((0, lodash_1.get)(process.env, 'PROJECT_RETRY_DELAY', 5000).toString(), 10),
86
130
  submissionProxy: true,
87
131
  processAsClient: true,
132
+ // Bodies are parsed before authentication, so keep them to what Form.io accepts.
133
+ maxBodySize: (0, lodash_1.get)(process.env, 'MAX_BODY_SIZE', '16mb').toString(),
88
134
  auth: { pkce: true, mcpOAuth: (0, lodash_1.get)(process.env, 'MCP_OAUTH', '').toString() === 'true' },
89
135
  database: databaseFromEnv()
90
136
  }));
@@ -94,6 +140,7 @@ class UAGServer extends appserver_1.Server {
94
140
  delete this.module.db;
95
141
  delete this.config.db;
96
142
  }
143
+ warnAboutSettings(this.config, secretGiven);
97
144
  this.use({
98
145
  ProjectInterface: UAGProjectInterface_1.UAGProjectInterface,
99
146
  FormInterface: UAGFormInterface_1.UAGFormInterface,
@@ -163,6 +210,18 @@ class UAGServer extends appserver_1.Server {
163
210
  console.warn('[formio-uag] MCP_OAUTH is set, but remote MCP sign-in is off: it needs the UAG\'s database (MONGO), and a license that includes the UAG to use it.');
164
211
  }
165
212
  }
213
+ /**
214
+ * An Agent API request that failed before its handlers (a body that is not JSON,
215
+ * one over the size limit) is answered in the Agent API's error shape; every other
216
+ * route keeps Form.io's plain-text answer.
217
+ */
218
+ sendError(req, res, status, message, err) {
219
+ if (!(0, errors_1.isAgentApiRequest)(req))
220
+ return super.sendError(req, res, status, message, err);
221
+ const error = err?.type === 'entity.parse.failed' ? 'The request body is not valid JSON.' : message;
222
+ // Written past any shaping wrapper, which may not have run for this request.
223
+ res.status(status).type('application/json').end(JSON.stringify((0, errors_1.errorBody)((0, errors_1.requestIdOf)(req, res), status, { error })));
224
+ }
166
225
  /** Browsers reading Agent API responses need the request id too. */
167
226
  get exposedHeaders() {
168
227
  return [...super.exposedHeaders, 'X-Request-Id'];
@@ -181,6 +240,8 @@ class UAGServer extends appserver_1.Server {
181
240
  await super.extendRouter(router);
182
241
  const uagProject = this.uagProject;
183
242
  if (uagProject) {
243
+ // First: the Agent API's request id and one error shape, for every refusal after it.
244
+ router.use((0, errors_1.agentErrorShape)());
184
245
  router.use(uagProject.agentRouter());
185
246
  // An agent operation of a form the project does not have is answered here, not passed to Form.io.
186
247
  router.use((0, router_1.agentNotFound)(uagProject));
package/lib/runner.d.ts CHANGED
@@ -53,6 +53,8 @@ export type TaskRunOutcome = {
53
53
  }[];
54
54
  /** Whether the results were saved (nothing is saved when nothing changed). */
55
55
  saved: boolean;
56
+ /** Answers the form's validation refused, left out: path to the form's message. */
57
+ refused?: Record<string, string>;
56
58
  error?: string;
57
59
  };
58
60
  /**
package/lib/runner.js CHANGED
@@ -268,8 +268,9 @@ class TaskRunner {
268
268
  const service = (0, formflow_1.formflow)(this.project);
269
269
  const load = async () => {
270
270
  const stored = await form.loadSubmission(job.submission, await act());
271
+ // Form.io answers a submission the caller may not read as missing, so say both.
271
272
  if (!stored)
272
- throw new formflow_1.FormflowError(404, `Submission "${job.submission}" was not found.`);
273
+ throw new formflow_1.FormflowError(404, `Submission "${job.submission}" was not found, or the user the run acts as may not read it (give submitters Read Own access, or the action an agent role).`);
273
274
  return stored;
274
275
  };
275
276
  let stored = await load();
@@ -278,8 +279,14 @@ class TaskRunner {
278
279
  const engines = run.results.map((result) => ({ kind: result.kind, engine: result.engine ?? null, model: result.model ?? null }));
279
280
  const failed = run.failed.map((group) => group.error || 'no engine could answer').join('; ');
280
281
  const changed = Object.keys(run.written).length > 0 || run.cleared.length > 0;
282
+ const refused = run.refused || {};
283
+ if (Object.keys(refused).length) {
284
+ console.warn(`[formio-uag] Run task "${job.task}" left out answers the form refused for submission ${job.submission} of "${job.form}": ` +
285
+ Object.entries(refused).map(([path, message]) => `${path} (${message})`).join('; '));
286
+ }
287
+ const left = Object.keys(refused).length ? { refused } : {};
281
288
  if (!changed) {
282
- return { status: failed ? 'failed' : 'done', written: {}, cleared: [], engines, saved: false, ...(failed && { error: failed }) };
289
+ return { status: failed ? 'failed' : 'done', written: {}, cleared: [], engines, saved: false, ...left, ...(failed && { error: failed }) };
283
290
  }
284
291
  try {
285
292
  // Only the task's fields changed; `modified` refuses the save if a person saved in the meantime.
@@ -291,7 +298,7 @@ class TaskRunner {
291
298
  ...(job.caller ? { [exports.RUN_TASK_CALLER]: job.caller, [exports.RUN_TASK_IDENTITY]: job.identity } : { [exports.RUN_TASK_AS_USER]: true }),
292
299
  [exports.RUN_TASK_CHAIN]: (job.chain || 0) + 1
293
300
  });
294
- return { status: failed ? 'failed' : 'done', written: run.written, cleared: run.cleared, engines, saved: true, ...(failed && { error: failed }) };
301
+ return { status: failed ? 'failed' : 'done', written: run.written, cleared: run.cleared, engines, saved: true, ...left, ...(failed && { error: failed }) };
295
302
  }
296
303
  catch (err) {
297
304
  if (err?.name !== 'ConflictError' || attempt >= 2) {
@@ -313,6 +320,7 @@ function resultOf(outcome) {
313
320
  engines: outcome.engines,
314
321
  written: Object.keys(outcome.written),
315
322
  cleared: outcome.cleared,
323
+ ...(outcome.refused && { refused: Object.keys(outcome.refused) }),
316
324
  saved: outcome.saved,
317
325
  ...(outcome.error && { error: outcome.error })
318
326
  };
package/lib/runs.d.ts CHANGED
@@ -40,6 +40,8 @@ export type TaskRunRecord = {
40
40
  }[];
41
41
  written?: string[];
42
42
  cleared?: string[];
43
+ /** Paths whose answers the form's validation refused, left out of the save. */
44
+ refused?: string[];
43
45
  saved?: boolean;
44
46
  error?: string;
45
47
  created: Date;
@@ -77,7 +79,7 @@ export declare class TaskRunStore {
77
79
  /** Keep holding a run. False when another replica took it over. */
78
80
  renew(id: ObjectId): Promise<boolean>;
79
81
  /** Record how a run ended. Only the replica holding a durable run can. */
80
- finish(id: ObjectId, result: Pick<TaskRunRecord, 'engines' | 'written' | 'cleared' | 'saved' | 'error'> & {
82
+ finish(id: ObjectId, result: Pick<TaskRunRecord, 'engines' | 'written' | 'cleared' | 'refused' | 'saved' | 'error'> & {
81
83
  status: 'done' | 'failed';
82
84
  }): Promise<void>;
83
85
  get(id: string): Promise<TaskRunRecord | null>;
@@ -113,11 +113,6 @@ export declare class SchemaBuilder {
113
113
  * components' `uag` property).
114
114
  */
115
115
  task(): this;
116
- /**
117
- * The legacy name for `task`. Kept in the schema so clients that still send
118
- * it are not silently given the default task.
119
- */
120
- persona(): this;
121
116
  /**
122
117
  * The data_path of a specific field within the form.
123
118
  */
@@ -175,14 +175,6 @@ class SchemaBuilder {
175
175
  this.schema.task = zod_1.default.string().optional().describe('The name of a task defined on the form: the value of its components\' `uag` property, such as "triage". Not a description of the work. If not provided, the form\'s first task is used.');
176
176
  return this;
177
177
  }
178
- /**
179
- * The legacy name for `task`. Kept in the schema so clients that still send
180
- * it are not silently given the default task.
181
- */
182
- persona() {
183
- this.schema.persona = zod_1.default.string().optional().describe('Deprecated: the former name for `task`. Send `task` instead.');
184
- return this;
185
- }
186
178
  /**
187
179
  * The data_path of a specific field within the form.
188
180
  */
@@ -22,7 +22,7 @@ const agentDecide = async (project) => {
22
22
  description: 'Return the decision questions for a task\'s closed-set fields (radio, select, checkbox, select boxes, survey and small resource selects) as structured JSON: the state to decide on, every question, and how each answer maps back to a field value. Answer the questions with a decision model, then write the values with `submission_update`. Provide either `submission_id` or `form_data`.',
23
23
  annotations: (0, annotations_1.reads)('Get decision questions'),
24
24
  inputSchema: {
25
- ...(new SchemaBuilder_1.SchemaBuilder(project)).form_name().submission_id().task().persona().schema,
25
+ ...(new SchemaBuilder_1.SchemaBuilder(project)).form_name().submission_id().task().schema,
26
26
  form_data: zod_1.default.record(zod_1.default.string(), zod_1.default.any()).optional().describe('The current form data as a submission data object (nested by data path). Used when `submission_id` is not given.')
27
27
  },
28
28
  execute: async (args, extra) => {
@@ -40,7 +40,7 @@ const agentDecide = async (project) => {
40
40
  data = submission.data || {};
41
41
  }
42
42
  try {
43
- const task = (0, formflow_1.resolveTaskName)(args, 'agent_decide');
43
+ const task = args.task || undefined;
44
44
  const out = await (0, formflow_1.formflow)(project).questions(form, { task, data, submissionId: submission_id }, extra.authInfo);
45
45
  return project.mcpJSONResponse({ form: form_name, ...out });
46
46
  }
@@ -6,8 +6,3 @@ import { ToolInfo } from "./utils";
6
6
  * agent writes its values with `submission_update`.
7
7
  */
8
8
  export declare const agentProvide: (project: UAGProjectInterface) => Promise<ToolInfo>;
9
- /**
10
- * `agent_provide_data`: the earlier name of `agent_provide`, kept so MCP clients
11
- * and integrations that call it keep working. Removed in a future major release.
12
- */
13
- export declare const agentProvideData: (project: UAGProjectInterface) => Promise<ToolInfo>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.agentProvideData = exports.agentProvide = void 0;
3
+ exports.agentProvide = void 0;
4
4
  const template_1 = require("../template");
5
5
  const lodash_1 = require("lodash");
6
6
  const SchemaBuilder_1 = require("./SchemaBuilder");
@@ -29,66 +29,33 @@ async function refusal(project) {
29
29
  * agent writes its values with `submission_update`.
30
30
  */
31
31
  const agentProvide = async (project) => {
32
- // Overrides written for the tool's earlier name still apply, except a rename,
33
- // which belongs to the deprecated tool and would clash with it.
34
- const overrides = project.config?.toolOverrides;
35
- const own = overrides?.agent_provide || (overrides?.agent_provide_data ? (0, lodash_1.omit)(overrides.agent_provide_data, 'name') : {});
36
32
  // Merged into a new object: the overrides are shared config, and the tools are
37
33
  // built again for every request.
38
- return (0, lodash_1.defaultsDeep)({}, own, provideTool(project, 'agent_provide'));
34
+ return (0, lodash_1.defaultsDeep)({}, project.config?.toolOverrides?.agent_provide, provideTool(project));
39
35
  };
40
36
  exports.agentProvide = agentProvide;
41
- /**
42
- * `agent_provide_data`: the earlier name of `agent_provide`, kept so MCP clients
43
- * and integrations that call it keep working. Removed in a future major release.
44
- */
45
- const agentProvideData = async (project) => {
46
- const tool = provideTool(project, 'agent_provide_data');
47
- const execute = tool.execute;
48
- return (0, lodash_1.defaultsDeep)({}, project.config?.toolOverrides?.agent_provide_data, {
49
- ...tool,
50
- title: 'Agent Provide Data (deprecated)',
51
- description: `Deprecated: use \`agent_provide\`, which takes the same arguments. ${DESCRIPTION}`,
52
- execute: (args, extra) => {
53
- (0, formflow_1.warnLegacy)('agent_provide_data', 'the agent_provide_data MCP tool; use agent_provide.');
54
- return execute(args, extra);
55
- }
56
- });
57
- };
58
- exports.agentProvideData = agentProvideData;
59
- function provideTool(project, name) {
37
+ function provideTool(project) {
60
38
  return {
61
- name,
39
+ name: 'agent_provide',
62
40
  title: 'Agent Provide',
63
41
  description: DESCRIPTION,
64
42
  annotations: (0, annotations_1.reads)('Process submission data'),
65
43
  inputSchema: (new SchemaBuilder_1.SchemaBuilder(project))
66
44
  .form_name()
67
45
  .submission_id()
68
- .task()
69
- .persona().schema,
46
+ .task().schema,
70
47
  execute: async (args, extra) => {
71
48
  const { form_name, submission_id } = args;
72
49
  const refused = await refusal(project);
73
50
  if (refused)
74
51
  return project.mcpJSONResponse(refused, true);
75
- let requested;
76
- try {
77
- requested = (0, formflow_1.resolveTaskName)(args, name);
78
- }
79
- catch (err) {
80
- if (err instanceof formflow_1.FormflowError)
81
- return project.mcpJSONResponse({ error: err.message }, true);
82
- throw err;
83
- }
52
+ const requested = args.task || undefined;
84
53
  const form = await project.getForm(form_name);
85
54
  if (!form) {
86
55
  return project.mcpResponse(template_1.ResponseTemplate.formNotFound, { formName: form_name }, true);
87
56
  }
88
57
  const task = requested || Object.keys(form.uagFields)[0] || 'default';
89
- // Templates get the task under both names: deployments may have
90
- // customized a template that still renders `persona`.
91
- const names = { task, persona: task };
58
+ const names = { task };
92
59
  const uag = form.uagFields[task];
93
60
  if (!uag) {
94
61
  return project.mcpResponse(template_1.ResponseTemplate.uagComponentNotFound, {
@@ -8,10 +8,10 @@ import { findSubmission } from "./findSubmission";
8
8
  import { submissionUpdate } from "./submissionUpdate";
9
9
  import { ToolInfo } from "./utils";
10
10
  import { UAGProjectInterface } from "../UAGProjectInterface";
11
- import { agentProvide, agentProvideData } from "./agentProvide";
11
+ import { agentProvide } from "./agentProvide";
12
12
  import { agentDecide } from "./agentDecide";
13
13
  import { fetchExternalData } from "./fetchExternalData";
14
14
  export * from './utils';
15
15
  export * from './annotations';
16
- export { getForms, getFormFields, getFieldInfo, collectData, confirmSubmission, submitCompletedForm, findSubmission, submissionUpdate, fetchExternalData, agentProvide, agentProvideData, agentDecide };
16
+ export { getForms, getFormFields, getFieldInfo, collectData, confirmSubmission, submitCompletedForm, findSubmission, submissionUpdate, fetchExternalData, agentProvide, agentDecide };
17
17
  export declare const getTools: (project: UAGProjectInterface) => Promise<ToolInfo[]>;
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.getTools = exports.agentDecide = exports.agentProvideData = exports.agentProvide = exports.fetchExternalData = exports.submissionUpdate = exports.findSubmission = exports.submitCompletedForm = exports.confirmSubmission = exports.collectData = exports.getFieldInfo = exports.getFormFields = exports.getForms = void 0;
17
+ exports.getTools = exports.agentDecide = exports.agentProvide = exports.fetchExternalData = exports.submissionUpdate = exports.findSubmission = exports.submitCompletedForm = exports.confirmSubmission = exports.collectData = exports.getFieldInfo = exports.getFormFields = exports.getForms = void 0;
18
18
  const getForms_1 = require("./getForms");
19
19
  Object.defineProperty(exports, "getForms", { enumerable: true, get: function () { return getForms_1.getForms; } });
20
20
  const getFormFields_1 = require("./getFormFields");
@@ -33,7 +33,6 @@ const submissionUpdate_1 = require("./submissionUpdate");
33
33
  Object.defineProperty(exports, "submissionUpdate", { enumerable: true, get: function () { return submissionUpdate_1.submissionUpdate; } });
34
34
  const agentProvide_1 = require("./agentProvide");
35
35
  Object.defineProperty(exports, "agentProvide", { enumerable: true, get: function () { return agentProvide_1.agentProvide; } });
36
- Object.defineProperty(exports, "agentProvideData", { enumerable: true, get: function () { return agentProvide_1.agentProvideData; } });
37
36
  const agentDecide_1 = require("./agentDecide");
38
37
  Object.defineProperty(exports, "agentDecide", { enumerable: true, get: function () { return agentDecide_1.agentDecide; } });
39
38
  const fetchExternalData_1 = require("./fetchExternalData");
@@ -52,8 +51,6 @@ const getTools = async (project) => {
52
51
  (0, submissionUpdate_1.submissionUpdate)(project),
53
52
  (0, agentProvide_1.agentProvide)(project),
54
53
  (0, agentDecide_1.agentDecide)(project),
55
- // Deprecated: the earlier name of agent_provide.
56
- (0, agentProvide_1.agentProvideData)(project),
57
54
  (0, fetchExternalData_1.fetchExternalData)(project)
58
55
  ]);
59
56
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formio/uag",
3
- "version": "2.0.0-rc.2",
3
+ "version": "2.0.0-rc.4",
4
4
  "mcpName": "io.form/formio-uag",
5
5
  "description": "The Form.io Universal Agent Gateway (UAG).",
6
6
  "main": "lib/index.js",
@@ -26,6 +26,7 @@
26
26
  "build:docker": "docker build -t formio/uag$(node -p \"require('./package.json').version.includes('-rc.') ? ':rc' : ''\") --platform=linux/amd64 .",
27
27
  "test": "tsc --noEmit -p tsconfig.test.json && mocha --no-node-snapshot",
28
28
  "test:e2e": "mocha --config .mocharc.e2e.json",
29
+ "test:getting-started": "mocha --no-config --node-option no-node-snapshot --require dotenv/config --timeout 1800000 test/e2e/layer3.getting-started.e2e.mjs",
29
30
  "release": "node scripts/release.mjs",
30
31
  "release:version": "node scripts/set-version.mjs",
31
32
  "dev": "tsx --no-node-snapshot index.js",
@@ -42,10 +43,9 @@
42
43
  "author": "Form.io Developers",
43
44
  "license": "MIT",
44
45
  "dependencies": {
45
- "@formio/appserver": "^2.24.1",
46
- "@formio/appserver-core": "^2.24.1",
46
+ "@formio/appserver": "^2.25.1",
47
47
  "@formio/core": "2.9.0",
48
- "@formio/uag-sdk": "^2.0.0-rc.2",
48
+ "@formio/uag-sdk": "^2.0.0-rc.4",
49
49
  "@modelcontextprotocol/sdk": "1.32.2",
50
50
  "cors": "^2.8.6",
51
51
  "debug": "^4.4.3",
@@ -68,20 +68,20 @@
68
68
  },
69
69
  "devDependencies": {
70
70
  "@anthropic-ai/sdk": "^1.3.0",
71
- "@formio/js": "5.6.1",
71
+ "@formio/js": "5.7.0",
72
72
  "@types/chai": "^5.2.3",
73
73
  "@types/cors": "^2.8.19",
74
74
  "@types/debug": "^4.1.13",
75
75
  "@types/dotenv": "^8.2.3",
76
76
  "@types/express": "^5.0.6",
77
- "@types/jsdom": "^30",
77
+ "@types/jsdom": "^30.0.0",
78
78
  "@types/jsonwebtoken": "^9.0.10",
79
79
  "@types/lodash": "^4.17.27",
80
80
  "@types/mocha": "^10.0.10",
81
- "@types/node": "^26.6.5",
81
+ "@types/node": "^26.6.6",
82
82
  "@types/supertest": "^7.2.1",
83
83
  "chai": "^6.3.0",
84
- "jsdom": "30.1.2",
84
+ "jsdom": "30.1.3",
85
85
  "mocha": "^12.0.3",
86
86
  "mongodb-memory-server": "^11.3.0",
87
87
  "playwright": "1.64.1",