@firedrill-tools/unified 0.1.1
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/LICENSE +201 -0
- package/README.md +242 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounds.scenario.json +23 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/large-channels.scenario.json +4251 -0
- package/firedrill/many-workspaces.scenario.json +1623 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/unified/behavior.mjs +69 -0
- package/firedrill/tools/unified/lib/assoc.mjs +61 -0
- package/firedrill/tools/unified/lib/enums.mjs +14 -0
- package/firedrill/tools/unified/lib/errors.mjs +50 -0
- package/firedrill/tools/unified/lib/events.mjs +27 -0
- package/firedrill/tools/unified/lib/identity.mjs +59 -0
- package/firedrill/tools/unified/lib/query.mjs +154 -0
- package/firedrill/tools/unified/lib/shapes.mjs +176 -0
- package/firedrill/tools/unified/lib/store.mjs +75 -0
- package/firedrill/tools/unified/lib/time.mjs +59 -0
- package/firedrill/tools/unified/lib/util.mjs +92 -0
- package/firedrill/tools/unified/lib/validate.mjs +114 -0
- package/firedrill/tools/unified/lib/wire.mjs +120 -0
- package/firedrill/tools/unified/ops/channels.mjs +45 -0
- package/firedrill/tools/unified/ops/connections.mjs +151 -0
- package/firedrill/tools/unified/ops/crm.mjs +126 -0
- package/firedrill/tools/unified/ops/deals.mjs +123 -0
- package/firedrill/tools/unified/ops/messages.mjs +133 -0
- package/firedrill/tools/unified/ops/pipelines.mjs +20 -0
- package/firedrill/tools/unified/ops/resource.mjs +114 -0
- package/firedrill/tools/unified/unified.tool.json +14068 -0
- package/firedrill/unified-bounds.drill.json +148 -0
- package/firedrill/unified-connections-flow.drill.json +226 -0
- package/firedrill/unified-crm-flow.drill.json +576 -0
- package/firedrill/unified-denied.drill.json +83 -0
- package/firedrill/unified-error-coverage.drill.json +528 -0
- package/firedrill/unified-fresh-actor.drill.json +83 -0
- package/firedrill/unified-invalid-workspace.drill.json +933 -0
- package/firedrill/unified-large-channels.drill.json +68 -0
- package/firedrill/unified-many-workspaces.drill.json +943 -0
- package/firedrill/unified-mcp-core-aliases.drill.json +113 -0
- package/firedrill/unified-messaging-flow.drill.json +326 -0
- package/firedrill/unified-rate-limited.drill.json +933 -0
- package/firedrill/unified-scoped-token.drill.json +113 -0
- package/firedrill/unified-size-bounds.drill.json +263 -0
- package/firedrill/unified-write-committed-lost.drill.json +107 -0
- package/firedrill/unified-write-unavailable.drill.json +528 -0
- package/firedrill/world.json +2958 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +2446 -0
- package/test/conformance.mjs +39 -0
- package/test/flows/connections.mjs +87 -0
- package/test/flows/coverage.mjs +64 -0
- package/test/flows/crm.mjs +124 -0
- package/test/flows/faults.mjs +82 -0
- package/test/flows/identity.mjs +84 -0
- package/test/flows/messaging.mjs +82 -0
- package/test/flows/size.mjs +71 -0
- package/test/lib.mjs +133 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Drill unified-size-bounds (admin, baseline): schema bounds, prototype keys, long unknown keys and the 900 KB page
|
|
2
|
+
// budget measured in UTF-8 bytes for contacts (raw), companies, deals and CJK messages.
|
|
3
|
+
import { C, CH, K, apiError, assert, get, post } from "../lib.mjs";
|
|
4
|
+
|
|
5
|
+
const M = `/crm/${C.main}`;
|
|
6
|
+
const H = `/messaging/${C.chat}`;
|
|
7
|
+
const CJK = "山田物産との商談が進んでいます。新しい四半期も頑張りましょう。";
|
|
8
|
+
|
|
9
|
+
const pagesOf = async (path, limit) => {
|
|
10
|
+
const sizes = [];
|
|
11
|
+
for (let offset = 0; ; offset += limit) {
|
|
12
|
+
const result = await get(`${path}${path.includes("?") ? "&" : "?"}limit=${limit}&offset=${offset}`);
|
|
13
|
+
assert.ok(Buffer.byteLength(result.text) <= 900000, `${path} page exceeds the byte budget`);
|
|
14
|
+
sizes.push(result.json.length);
|
|
15
|
+
if (result.json.length < limit) return sizes;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function sizeBounds() {
|
|
20
|
+
// Schema bounds and hostile keys on contact bodies (six declared errors, nothing written).
|
|
21
|
+
await apiError("POST", `${M}/contact`, 400, "at most 1000 characters", { body: { name: "n".repeat(1001) } });
|
|
22
|
+
await apiError("POST", `${M}/contact`, 400, "at most 32 entries", { body: { name: "Many", emails: Array.from({ length: 33 }, (_, i) => ({ email: `m${i}@example.org` })) } });
|
|
23
|
+
await apiError("POST", `${M}/contact`, 400, "at most 100 entries", { body: { name: "Many", company_ids: Array.from({ length: 101 }, (_, i) => `68c8000000000000000${(0x90000 + i).toString(16).padStart(5, "0")}`) } });
|
|
24
|
+
let deep = { leaf: 1 };
|
|
25
|
+
for (let i = 0; i < 9; i += 1) deep = { nested: deep };
|
|
26
|
+
await apiError("POST", `${M}/contact`, 400, "deeper than 8 levels", { body: { name: "Deep", raw: deep } });
|
|
27
|
+
const guarded = await apiError("POST", `${M}/contact`, 400, "Invalid key", { rawBody: '{"name":"Poison","raw":{"constructor":{"prototype":{"polluted":true}}}}' });
|
|
28
|
+
assert.ok(!guarded.text.includes("Cannot read"), "no runtime error text leaks");
|
|
29
|
+
const twoKeys = await apiError("POST", `${M}/contact`, 400, "Invalid key", { rawBody: '{"name":"Poison","raw":{"a":{"prototype":1}}}' });
|
|
30
|
+
assert.ok(!twoKeys.text.includes("Cannot read"));
|
|
31
|
+
// A JSON `__proto__` member is not probed here: depending on the framework build it is either dropped by the body
|
|
32
|
+
// parser (200, raw {}) or refused by the codec's key guard (400), which would make the flow's counts build-dependent.
|
|
33
|
+
// The route fuzzer (tooling/route-fuzz.mjs) covers realm poisoning; the guard itself is proven above with `constructor`/`prototype`.
|
|
34
|
+
const longKey = "k".repeat(5000);
|
|
35
|
+
const unknown = await apiError("POST", `${M}/contact`, 400, "Unknown field", { body: { name: "Long", [longKey]: 1 } });
|
|
36
|
+
assert.ok(unknown.json.message.length < 400, "caller keys are clipped in error messages");
|
|
37
|
+
const elena = (await get(`${M}/contact/${K.elena}`)).json;
|
|
38
|
+
assert.ok(!Object.hasOwn(elena, "polluted") && ({}).polluted === undefined, "the shared realm was not poisoned");
|
|
39
|
+
assert.equal((await get(`${M}/contact`)).json.length, 8);
|
|
40
|
+
|
|
41
|
+
// 60 contacts with a 16 KB raw payload each: raw is hidden by default and too large as one page when requested.
|
|
42
|
+
const blob = "r".repeat(16000);
|
|
43
|
+
for (let i = 0; i < 60; i += 1) assert.equal((await post(`${M}/contact`, { name: `Bulk contact ${i + 1}`, raw: { blob } })).json.name, `Bulk contact ${i + 1}`);
|
|
44
|
+
assert.equal((await get(`${M}/contact?limit=100`)).json.length, 68, "the default projection without raw fits one page");
|
|
45
|
+
await apiError("GET", `${M}/contact?fields=id,raw&limit=100`, 413, "Response exceeds 1 MB");
|
|
46
|
+
assert.deepEqual(await pagesOf(`${M}/contact?fields=id,raw`, 50), [50, 18]);
|
|
47
|
+
|
|
48
|
+
// 50 companies and 50 deals with 20,000-character descriptions.
|
|
49
|
+
const text = "d".repeat(20000);
|
|
50
|
+
for (let i = 0; i < 50; i += 1) assert.equal((await post(`${M}/company`, { name: `Bulk company ${i + 1}`, description: text })).json.description.length, 20000);
|
|
51
|
+
await apiError("GET", `${M}/company`, 413, "Response exceeds 1 MB");
|
|
52
|
+
assert.deepEqual(await pagesOf(`${M}/company`, 30), [30, 25]);
|
|
53
|
+
for (let i = 0; i < 50; i += 1) assert.equal((await post(`${M}/deal`, { name: `Bulk deal ${i + 1}`, description: text })).json.name, `Bulk deal ${i + 1}`);
|
|
54
|
+
await apiError("GET", `${M}/deal?limit=100`, 413, "Response exceeds 1 MB");
|
|
55
|
+
assert.deepEqual(await pagesOf(`${M}/deal`, 30), [30, 26]);
|
|
56
|
+
|
|
57
|
+
// 16 messages of 20,000 CJK characters (60 KB each on the wire): a character-counting budget would let the page through.
|
|
58
|
+
const cjk = CJK.repeat(Math.ceil(20000 / CJK.length)).slice(0, 20000);
|
|
59
|
+
assert.equal(cjk.length, 20000);
|
|
60
|
+
for (let i = 0; i < 16; i += 1) assert.equal((await post(`${H}/message`, { message: cjk, channels: [{ id: CH.exec }] })).json.message.length, 20000);
|
|
61
|
+
await apiError("GET", `${H}/message?channel_id=${CH.exec}&limit=100`, 413, "Response exceeds 1 MB");
|
|
62
|
+
assert.deepEqual(await pagesOf(`${H}/message?channel_id=${CH.exec}`, 10), [10, 8]);
|
|
63
|
+
|
|
64
|
+
// Long and repeated query parameters.
|
|
65
|
+
await apiError("GET", `${H}/message?query=${"*".repeat(10000)}`, 400, "query must be a string of at most 2000 characters");
|
|
66
|
+
assert.equal((await get(`${H}/message?channel_id=${CH.sales}&fields=${"f".repeat(5000)},id`)).json.length, 5, "over-long field names are ignored");
|
|
67
|
+
await apiError("GET", `${H}/message?${Array.from({ length: 17 }, (_, i) => `limit=${i + 1}`).join("&")}`, 400, "repeated more than 16 times");
|
|
68
|
+
// A wildcard-heavy query is a literal substring search (linear time); the crash fuzzer separately enforces the 15 s hang bound.
|
|
69
|
+
assert.equal((await get(`${H}/message?channel_id=${CH.sales}&query=${encodeURIComponent("*a".repeat(900))}`)).json.length, 0);
|
|
70
|
+
assert.equal((await get(`${H}/message?channel_id=${CH.sales}&query=${encodeURIComponent("*")}`)).json.length, 0, "* is a literal character, not a wildcard");
|
|
71
|
+
}
|
package/test/lib.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Shared transport helpers and starter ids for the conformance flows. Node built-ins only.
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
export const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
5
|
+
export const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
6
|
+
export const MCP = process.env.FIREDRILL_MCP_URL;
|
|
7
|
+
export const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
|
|
8
|
+
assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
|
|
9
|
+
|
|
10
|
+
const P = "68c8000000000000000";
|
|
11
|
+
export const id = (suffix) => `${P}${String(suffix).padStart(5, "0")}`;
|
|
12
|
+
export const WS = "68c8000000000000000000a1";
|
|
13
|
+
export const NOW = "2026-09-16T09:00:00.000Z";
|
|
14
|
+
export const C = { main: id("0201"), readonly: id("0202"), chat: id("0203"), mail: id("0204"), paused: id("0205"), broken: id("0206"), hris: id("0207"), sandbox: id("0208"), omni: id("0209"), none: id("0999") };
|
|
15
|
+
export const CO = { northwind: id("0401"), contoso: id("0402"), fabrikam: id("0403"), tailspin: id("0404"), yamada: id("0405") };
|
|
16
|
+
export const K = { elena: id("0501"), tomas: id("0502"), priya: id("0503"), jonas: id("0504"), ravi: id("0505"), ana: id("0506"), liwei: id("0507"), sam: id("0508"), dana: id("0509"), sandbox: id("0510") };
|
|
17
|
+
export const D = { q4: id("0601"), renewal: id("0602"), pilot: id("0603"), rebrand: id("0604"), logistics: id("0605"), inbound: id("0606") };
|
|
18
|
+
export const PL = { sales: id("0301"), renewals: id("0302"), qualification: id("0311"), proposal: id("0312"), negotiation: id("0313"), won: id("0314"), lost: id("0315"), upcoming: id("0321") };
|
|
19
|
+
export const CH = { general: id("0701"), sales: id("0702"), exec: id("0703"), emea: id("0704"), inbox: id("0711"), sent: id("0712"), draft: id("0713") };
|
|
20
|
+
export const MSG = (n) => id((0x0800 + n).toString(16).padStart(4, "0"));
|
|
21
|
+
export const NEW = (n) => `68c8${"0".repeat(14)}${(4096 + n).toString(16).padStart(6, "0")}`;
|
|
22
|
+
export const NONE = id("0999");
|
|
23
|
+
|
|
24
|
+
export const ALL_OPERATIONS = [
|
|
25
|
+
"connections.list", "connections.get", "connections.create", "connections.update", "connections.remove",
|
|
26
|
+
...["contacts", "companies", "deals"].flatMap((p) => ["list", "get", "create", "update", "remove"].map((v) => `${p}.${v}`)),
|
|
27
|
+
"pipelines.list", "pipelines.get", "channels.list", "channels.get",
|
|
28
|
+
"messages.list", "messages.get", "messages.create", "messages.update", "messages.remove",
|
|
29
|
+
];
|
|
30
|
+
export const ALIASES = ["list_unified_connections", "get_unified_connection", "create_unified_connection", "update_unified_connection", "remove_unified_connection"];
|
|
31
|
+
|
|
32
|
+
/** Provider-shaped request; asserts the status and returns the parsed body, raw text and headers. */
|
|
33
|
+
export async function api(method, path, { body, status = 200, rawBody, contentType, headers = {} } = {}) {
|
|
34
|
+
const hasBody = body !== undefined || rawBody !== undefined;
|
|
35
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
36
|
+
method,
|
|
37
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, ...(hasBody ? { "content-type": contentType ?? "application/json" } : {}), ...headers },
|
|
38
|
+
...(hasBody ? { body: rawBody ?? JSON.stringify(body) } : {}),
|
|
39
|
+
});
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
let json;
|
|
42
|
+
try {
|
|
43
|
+
json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
json = undefined;
|
|
46
|
+
}
|
|
47
|
+
assert.equal(response.status, status, `${method} ${path} ${body === undefined ? "" : JSON.stringify(body).slice(0, 160)} -> ${response.status} ${text.slice(0, 400)}`);
|
|
48
|
+
return { json, text, headers: response.headers, status: response.status };
|
|
49
|
+
}
|
|
50
|
+
export const get = (path, options) => api("GET", path, options);
|
|
51
|
+
export const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
52
|
+
export const put = (path, body, options) => api("PUT", path, { ...options, body });
|
|
53
|
+
export const patch = (path, body, options) => api("PATCH", path, { ...options, body });
|
|
54
|
+
export const del = (path, options) => api("DELETE", path, options);
|
|
55
|
+
|
|
56
|
+
/** Expects the Tool's error envelope `{ statusCode, message, error }` with the given status and a message fragment. */
|
|
57
|
+
export async function apiError(method, path, status, fragment, options = {}) {
|
|
58
|
+
const result = await api(method, path, { ...options, status });
|
|
59
|
+
assert.ok(result.json && typeof result.json === "object", `${method} ${path}: no JSON envelope: ${result.text.slice(0, 300)}`);
|
|
60
|
+
assert.equal(result.json.statusCode, status, `${method} ${path}: envelope statusCode ${JSON.stringify(result.json).slice(0, 300)}`);
|
|
61
|
+
assert.equal(typeof result.json.message, "string", `${method} ${path}: envelope message missing`);
|
|
62
|
+
assert.equal(typeof result.json.error, "string", `${method} ${path}: envelope reason missing`);
|
|
63
|
+
if (fragment) assert.ok(result.json.message.includes(fragment), `${method} ${path}: expected ${JSON.stringify(fragment)} in ${result.json.message.slice(0, 300)}`);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Canonical operation endpoint; `expected` is "ok" or a Tool error code. */
|
|
68
|
+
export async function op(operationId, args, expected = "ok") {
|
|
69
|
+
const response = await fetch(`${HTTP}/v1/operations/unified/${operationId}`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" },
|
|
72
|
+
body: JSON.stringify({ arguments: args }),
|
|
73
|
+
});
|
|
74
|
+
const json = await response.json();
|
|
75
|
+
assert.ok(json.outcome, `canonical ${operationId}: ${JSON.stringify(json).slice(0, 300)}`);
|
|
76
|
+
if (expected === "ok") {
|
|
77
|
+
assert.equal(json.outcome.status, "ok", `${operationId} ${JSON.stringify(args).slice(0, 200)}: ${JSON.stringify(json.outcome).slice(0, 400)}`);
|
|
78
|
+
return json.outcome.value;
|
|
79
|
+
}
|
|
80
|
+
assert.equal(json.outcome.status, "tool_error", `${operationId}: ${JSON.stringify(json.outcome).slice(0, 400)}`);
|
|
81
|
+
assert.equal(json.outcome.error.code, `tool.${expected}`, `${operationId}: ${JSON.stringify(json.outcome.error)}`);
|
|
82
|
+
return json.outcome.error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let rpcId = 0;
|
|
86
|
+
export async function rpc(method, params) {
|
|
87
|
+
const response = await fetch(MCP, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { authorization: `Bearer ${MCP_TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
90
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
|
91
|
+
});
|
|
92
|
+
assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
const messages = (response.headers.get("content-type") ?? "").includes("text/event-stream")
|
|
95
|
+
? text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5).trim()))
|
|
96
|
+
: [JSON.parse(text)];
|
|
97
|
+
const reply = messages.find((message) => message.id === rpcId);
|
|
98
|
+
assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
|
|
99
|
+
if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
|
|
100
|
+
return reply.result;
|
|
101
|
+
}
|
|
102
|
+
export const mcpInit = () => rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "unified-conformance", version: "0.1.0" } });
|
|
103
|
+
/** Successful tool call; array results arrive wrapped as `{ result: [...] }`, objects verbatim. */
|
|
104
|
+
export async function mcp(name, args) {
|
|
105
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
106
|
+
assert.ok(!result.isError, `${name} ${JSON.stringify(args)}: ${JSON.stringify(result).slice(0, 500)}`);
|
|
107
|
+
return result.structuredContent;
|
|
108
|
+
}
|
|
109
|
+
export async function mcpError(name, args, code) {
|
|
110
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
111
|
+
assert.ok(result.isError, `${name} ${JSON.stringify(args)} unexpectedly succeeded`);
|
|
112
|
+
// Operations with an array output carry the failure under `result`, object-valued ones at the top level.
|
|
113
|
+
const failure = result.structuredContent?.status === "tool_error" ? result.structuredContent : result.structuredContent?.result;
|
|
114
|
+
if (code) assert.equal(failure?.error?.code, `tool.${code}`, JSON.stringify(result).slice(0, 400));
|
|
115
|
+
return failure;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const ids = (items) => items.map((item) => item.id);
|
|
119
|
+
export const noneHas = (items, key) => items.every((item) => !Object.hasOwn(item, key));
|
|
120
|
+
/** Walks an offset-paged list until a short page; returns every row. */
|
|
121
|
+
export async function walk(path, limit, expectedPages) {
|
|
122
|
+
const rows = [];
|
|
123
|
+
const sizes = [];
|
|
124
|
+
for (let offset = 0; ; offset += limit) {
|
|
125
|
+
const page = (await get(`${path}${path.includes("?") ? "&" : "?"}limit=${limit}&offset=${offset}`)).json;
|
|
126
|
+
sizes.push(page.length);
|
|
127
|
+
rows.push(...page);
|
|
128
|
+
if (page.length < limit) break;
|
|
129
|
+
}
|
|
130
|
+
if (expectedPages) assert.deepEqual(sizes, expectedPages, `${path} pages`);
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
export { assert };
|