@alexkroman1/aai 1.0.6 → 1.2.0
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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +22 -0
- package/dist/_internal-types-CoDTiBd1.js +61 -0
- package/dist/host/_mock-ws.d.ts +0 -24
- package/dist/host/runtime-barrel.d.ts +0 -1
- package/dist/host/runtime-barrel.js +55 -5
- package/dist/host/runtime.d.ts +2 -0
- package/dist/host/tool-executor.d.ts +1 -0
- package/dist/host/ws-handler.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +90 -1
- package/dist/sdk/allowed-hosts.d.ts +34 -0
- package/dist/sdk/manifest-barrel.d.ts +3 -5
- package/dist/sdk/manifest-barrel.js +2 -52
- package/dist/sdk/manifest.d.ts +2 -0
- package/dist/sdk/protocol.d.ts +11 -28
- package/dist/sdk/protocol.js +6 -3
- package/dist/sdk/types.d.ts +2 -0
- package/host/_mock-ws.ts +0 -50
- package/host/_test-utils.ts +1 -0
- package/host/runtime-barrel.ts +0 -1
- package/host/runtime.ts +13 -1
- package/host/session-ctx.test.ts +387 -0
- package/host/session-fixture-replay.test.ts +2 -10
- package/host/session.test.ts +19 -41
- package/host/tool-executor.test.ts +36 -0
- package/host/tool-executor.ts +4 -0
- package/host/ws-handler.ts +3 -0
- package/index.ts +1 -0
- package/package.json +1 -1
- package/sdk/__snapshots__/exports.test.ts.snap +79 -0
- package/sdk/__snapshots__/schema-shapes.test.ts.snap +187 -0
- package/sdk/_test-matchers.test.ts +75 -0
- package/sdk/_test-matchers.ts +73 -0
- package/sdk/allowed-hosts.test.ts +236 -0
- package/sdk/allowed-hosts.ts +113 -0
- package/sdk/exports.test.ts +31 -0
- package/sdk/manifest-barrel.ts +13 -7
- package/sdk/manifest.test.ts +103 -2
- package/sdk/manifest.ts +19 -0
- package/sdk/protocol-compat.test.ts +0 -6
- package/sdk/protocol-snapshot.test.ts +7 -5
- package/sdk/protocol.test.ts +107 -21
- package/sdk/protocol.ts +7 -15
- package/sdk/schema-alignment.test.ts +1 -27
- package/sdk/schema-shapes.test.ts +103 -0
- package/sdk/tsconfig.json +1 -1
- package/sdk/types.test.ts +56 -1
- package/sdk/types.ts +2 -0
- package/sdk/ws-upgrade.test.ts +8 -8
- package/tsconfig.build.json +8 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +1 -0
- package/dist/system-prompt-nik_iavo.js +0 -92
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { matchesAllowedHost, validateAllowedHostPattern } from "./allowed-hosts.ts";
|
|
4
|
+
|
|
5
|
+
describe("validateAllowedHostPattern", () => {
|
|
6
|
+
describe("valid patterns", () => {
|
|
7
|
+
test("accepts exact hostname", () => {
|
|
8
|
+
expect(validateAllowedHostPattern("api.weather.com")).toEqual({
|
|
9
|
+
valid: true,
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("accepts exact hostname without subdomain", () => {
|
|
14
|
+
expect(validateAllowedHostPattern("example.com")).toEqual({ valid: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("accepts wildcard subdomain", () => {
|
|
18
|
+
expect(validateAllowedHostPattern("*.mycompany.com")).toEqual({
|
|
19
|
+
valid: true,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("accepts wildcard with multiple domain levels", () => {
|
|
24
|
+
expect(validateAllowedHostPattern("*.api.mycompany.com")).toEqual({
|
|
25
|
+
valid: true,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("rejects bare wildcards", () => {
|
|
31
|
+
test("rejects bare *", () => {
|
|
32
|
+
const result = validateAllowedHostPattern("*");
|
|
33
|
+
expect(result.valid).toBe(false);
|
|
34
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/bare/i);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects bare **", () => {
|
|
38
|
+
const result = validateAllowedHostPattern("**");
|
|
39
|
+
expect(result.valid).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("rejects wildcard in non-leading position", () => {
|
|
44
|
+
test("rejects wildcard in middle position", () => {
|
|
45
|
+
const result = validateAllowedHostPattern("api.*.com");
|
|
46
|
+
expect(result.valid).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects wildcard at end", () => {
|
|
50
|
+
const result = validateAllowedHostPattern("api.com.*");
|
|
51
|
+
expect(result.valid).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("rejects IP addresses", () => {
|
|
56
|
+
test("rejects IPv4 address", () => {
|
|
57
|
+
const result = validateAllowedHostPattern("192.168.1.1");
|
|
58
|
+
expect(result.valid).toBe(false);
|
|
59
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/ip/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects IPv4 loopback", () => {
|
|
63
|
+
const result = validateAllowedHostPattern("127.0.0.1");
|
|
64
|
+
expect(result.valid).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects IPv6 address", () => {
|
|
68
|
+
const result = validateAllowedHostPattern("::1");
|
|
69
|
+
expect(result.valid).toBe(false);
|
|
70
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/ip/i);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rejects full IPv6 address", () => {
|
|
74
|
+
const result = validateAllowedHostPattern("2001:db8::1");
|
|
75
|
+
expect(result.valid).toBe(false);
|
|
76
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/ip/i);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("rejects private TLDs", () => {
|
|
81
|
+
test("rejects *.local", () => {
|
|
82
|
+
const result = validateAllowedHostPattern("*.local");
|
|
83
|
+
expect(result.valid).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects *.internal", () => {
|
|
87
|
+
const result = validateAllowedHostPattern("*.internal");
|
|
88
|
+
expect(result.valid).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects *.localhost", () => {
|
|
92
|
+
const result = validateAllowedHostPattern("*.localhost");
|
|
93
|
+
expect(result.valid).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("rejects exact match foo.local", () => {
|
|
97
|
+
const result = validateAllowedHostPattern("foo.local");
|
|
98
|
+
expect(result.valid).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rejects exact match foo.internal", () => {
|
|
102
|
+
const result = validateAllowedHostPattern("foo.internal");
|
|
103
|
+
expect(result.valid).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects exact match foo.localhost", () => {
|
|
107
|
+
const result = validateAllowedHostPattern("foo.localhost");
|
|
108
|
+
expect(result.valid).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("rejects cloud metadata hostnames", () => {
|
|
113
|
+
test("rejects metadata.google.internal", () => {
|
|
114
|
+
const result = validateAllowedHostPattern("metadata.google.internal");
|
|
115
|
+
expect(result.valid).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rejects instance-data.ec2.internal", () => {
|
|
119
|
+
const result = validateAllowedHostPattern("instance-data.ec2.internal");
|
|
120
|
+
expect(result.valid).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("rejects empty and malformed patterns", () => {
|
|
125
|
+
test("rejects empty string", () => {
|
|
126
|
+
const result = validateAllowedHostPattern("");
|
|
127
|
+
expect(result.valid).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rejects pattern with protocol", () => {
|
|
131
|
+
const result = validateAllowedHostPattern("https://api.example.com");
|
|
132
|
+
expect(result.valid).toBe(false);
|
|
133
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/protocol/i);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("rejects pattern with path", () => {
|
|
137
|
+
const result = validateAllowedHostPattern("api.example.com/path");
|
|
138
|
+
expect(result.valid).toBe(false);
|
|
139
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/path/i);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("rejects pattern with query", () => {
|
|
143
|
+
const result = validateAllowedHostPattern("api.example.com?query=1");
|
|
144
|
+
expect(result.valid).toBe(false);
|
|
145
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/query/i);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("rejects pattern with port", () => {
|
|
149
|
+
const result = validateAllowedHostPattern("api.example.com:8080");
|
|
150
|
+
expect(result.valid).toBe(false);
|
|
151
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/port/i);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("matchesAllowedHost", () => {
|
|
157
|
+
describe("exact matching", () => {
|
|
158
|
+
test("exact match", () => {
|
|
159
|
+
expect(matchesAllowedHost("api.example.com", ["api.example.com"])).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("case-insensitive exact match", () => {
|
|
163
|
+
expect(matchesAllowedHost("API.Example.COM", ["api.example.com"])).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("strips trailing dot from hostname", () => {
|
|
167
|
+
expect(matchesAllowedHost("api.example.com.", ["api.example.com"])).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("exact match doesn't match subdomain", () => {
|
|
171
|
+
expect(matchesAllowedHost("sub.api.example.com", ["api.example.com"])).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("exact match doesn't match parent domain", () => {
|
|
175
|
+
expect(matchesAllowedHost("example.com", ["api.example.com"])).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("wildcard matching", () => {
|
|
180
|
+
test("wildcard matches one subdomain level", () => {
|
|
181
|
+
expect(matchesAllowedHost("foo.example.com", ["*.example.com"])).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("wildcard matches multiple subdomain levels", () => {
|
|
185
|
+
expect(matchesAllowedHost("a.b.example.com", ["*.example.com"])).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("wildcard does not match bare domain", () => {
|
|
189
|
+
expect(matchesAllowedHost("example.com", ["*.example.com"])).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("wildcard is case-insensitive", () => {
|
|
193
|
+
expect(matchesAllowedHost("FOO.Example.COM", ["*.example.com"])).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("no match", () => {
|
|
198
|
+
test("no match returns false", () => {
|
|
199
|
+
expect(matchesAllowedHost("other.com", ["api.example.com"])).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("empty patterns returns false", () => {
|
|
203
|
+
expect(matchesAllowedHost("api.example.com", [])).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("multiple patterns", () => {
|
|
208
|
+
test("matches against multiple patterns — first matches", () => {
|
|
209
|
+
expect(matchesAllowedHost("api.example.com", ["api.example.com", "other.com"])).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("matches against multiple patterns — second matches", () => {
|
|
213
|
+
expect(matchesAllowedHost("other.com", ["api.example.com", "other.com"])).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("matches against multiple patterns — none match", () => {
|
|
217
|
+
expect(matchesAllowedHost("nope.com", ["api.example.com", "other.com"])).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("port handling", () => {
|
|
222
|
+
test("port in hostname is stripped before matching exact", () => {
|
|
223
|
+
expect(matchesAllowedHost("api.weather.com:8080", ["api.weather.com"])).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("port with wildcard pattern", () => {
|
|
227
|
+
expect(matchesAllowedHost("sub.example.com:443", ["*.example.com"])).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("IDN/punycode", () => {
|
|
232
|
+
test("ASCII hostnames compared normally", () => {
|
|
233
|
+
expect(matchesAllowedHost("xn--nxasmq6b.com", ["xn--nxasmq6b.com"])).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Allowlist matching for outbound host validation.
|
|
4
|
+
*
|
|
5
|
+
* Used at deploy time (manifest validation) and at runtime (SSRF enforcement)
|
|
6
|
+
* to restrict which external hosts an agent is permitted to contact.
|
|
7
|
+
*
|
|
8
|
+
* Lives in sdk/ because it has zero Node.js dependencies and can run in any
|
|
9
|
+
* environment (browser, Deno, Node.js sandboxes).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Private/special-use TLDs that must never appear in allowedHosts patterns. */
|
|
13
|
+
const BLOCKED_TLDS = ["local", "internal", "localhost"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Regex that matches an IPv4 address (four decimal octets separated by dots).
|
|
17
|
+
* Anchored so partial matches like "192.168.1.1.example.com" don't trigger it.
|
|
18
|
+
*/
|
|
19
|
+
const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
20
|
+
|
|
21
|
+
type ValidationResult = { valid: true } | { valid: false; reason: string };
|
|
22
|
+
|
|
23
|
+
function fail(reason: string): { valid: false; reason: string } {
|
|
24
|
+
return { valid: false, reason };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkStructural(pattern: string): ValidationResult | null {
|
|
28
|
+
if (pattern === "") return fail("Pattern must not be empty.");
|
|
29
|
+
if (pattern.includes("://"))
|
|
30
|
+
return fail("Pattern must not include a protocol (e.g. remove 'https://').");
|
|
31
|
+
if (pattern.includes("/")) return fail("Pattern must not include a path component (remove '/').");
|
|
32
|
+
if (pattern.includes("?")) return fail("Pattern must not include a query string (remove '?').");
|
|
33
|
+
if (pattern.startsWith("[") || pattern.includes("::"))
|
|
34
|
+
return fail("IP address literals are not allowed in allowedHosts patterns.");
|
|
35
|
+
if (pattern.includes(":"))
|
|
36
|
+
return fail("Pattern must not include a port number (e.g. remove ':8080').");
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkWildcard(pattern: string): ValidationResult | null {
|
|
41
|
+
if (!pattern.includes("*")) return null;
|
|
42
|
+
if (pattern === "*" || pattern === "**")
|
|
43
|
+
return fail("Bare wildcard '*' is not allowed. Use '*.example.com' to allow all subdomains.");
|
|
44
|
+
const wildcardIndex = pattern.indexOf("*");
|
|
45
|
+
if (wildcardIndex !== 0 || pattern[1] !== ".")
|
|
46
|
+
return fail("Wildcard '*' may only appear as the leading segment (e.g. '*.example.com').");
|
|
47
|
+
if (pattern.lastIndexOf("*") !== 0)
|
|
48
|
+
return fail("Only a single leading wildcard segment is supported.");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function checkHostPart(hostPart: string): ValidationResult | null {
|
|
53
|
+
if (IPV4_RE.test(hostPart))
|
|
54
|
+
return fail("IP address literals are not allowed in allowedHosts patterns.");
|
|
55
|
+
const tld = hostPart.split(".").at(-1)?.toLowerCase() ?? "";
|
|
56
|
+
if (BLOCKED_TLDS.includes(tld))
|
|
57
|
+
return fail(`Patterns ending in '.${tld}' are not allowed (private/special-use TLD).`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a single `allowedHosts` pattern at deploy time.
|
|
63
|
+
*
|
|
64
|
+
* Returns `{ valid: true }` for acceptable patterns or
|
|
65
|
+
* `{ valid: false; reason: string }` with a human-readable rejection reason.
|
|
66
|
+
*/
|
|
67
|
+
export function validateAllowedHostPattern(pattern: string): ValidationResult {
|
|
68
|
+
const structural = checkStructural(pattern);
|
|
69
|
+
if (structural !== null) return structural;
|
|
70
|
+
|
|
71
|
+
const wildcard = checkWildcard(pattern);
|
|
72
|
+
if (wildcard !== null) return wildcard;
|
|
73
|
+
|
|
74
|
+
const hostPart = pattern.startsWith("*.") ? pattern.slice(2) : pattern;
|
|
75
|
+
const hostCheck = checkHostPart(hostPart);
|
|
76
|
+
if (hostCheck !== null) return hostCheck;
|
|
77
|
+
|
|
78
|
+
return { valid: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Test whether `hostname` matches any pattern in `patterns`.
|
|
83
|
+
*
|
|
84
|
+
* - Exact match is case-insensitive; trailing dots on the hostname are stripped.
|
|
85
|
+
* - Wildcard pattern `*.example.com` matches any hostname ending with
|
|
86
|
+
* `.example.com` (one or more labels), but does NOT match `example.com` itself.
|
|
87
|
+
* - A port suffix on `hostname` (e.g. `api.example.com:8080`) is stripped before
|
|
88
|
+
* matching.
|
|
89
|
+
* - Returns `false` when `patterns` is empty.
|
|
90
|
+
*/
|
|
91
|
+
export function matchesAllowedHost(hostname: string, patterns: string[]): boolean {
|
|
92
|
+
if (patterns.length === 0) return false;
|
|
93
|
+
|
|
94
|
+
// Strip port — only when there are no brackets (IPv6 bracket notation).
|
|
95
|
+
const portIndex = hostname.lastIndexOf(":");
|
|
96
|
+
let host = portIndex !== -1 && !hostname.includes("[") ? hostname.slice(0, portIndex) : hostname;
|
|
97
|
+
|
|
98
|
+
// Normalise: lowercase + strip trailing dot
|
|
99
|
+
host = host.toLowerCase().replace(/\.$/, "");
|
|
100
|
+
|
|
101
|
+
for (const pattern of patterns) {
|
|
102
|
+
const p = pattern.toLowerCase();
|
|
103
|
+
if (p.startsWith("*.")) {
|
|
104
|
+
// Wildcard: hostname must end with the suffix (e.g. ".example.com")
|
|
105
|
+
const suffix = p.slice(1); // ".example.com"
|
|
106
|
+
if (host.endsWith(suffix) && host.length > suffix.length) return true;
|
|
107
|
+
} else if (host === p) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Export surface snapshot tests for all four aai subpath exports.
|
|
4
|
+
*
|
|
5
|
+
* These tests catch accidental export additions or removals. If a snapshot
|
|
6
|
+
* breaks, it signals a potentially breaking API change that should be
|
|
7
|
+
* reviewed and documented with a changeset.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
|
|
11
|
+
describe("export surface stability", () => {
|
|
12
|
+
test("@alexkroman1/aai main export", async () => {
|
|
13
|
+
const mod = await import("@alexkroman1/aai");
|
|
14
|
+
expect(Object.keys(mod).sort()).toMatchSnapshot();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("@alexkroman1/aai/protocol export", async () => {
|
|
18
|
+
const mod = await import("@alexkroman1/aai/protocol");
|
|
19
|
+
expect(Object.keys(mod).sort()).toMatchSnapshot();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("@alexkroman1/aai/manifest export", async () => {
|
|
23
|
+
const mod = await import("@alexkroman1/aai/manifest");
|
|
24
|
+
expect(Object.keys(mod).sort()).toMatchSnapshot();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("@alexkroman1/aai/runtime export", async () => {
|
|
28
|
+
const mod = await import("@alexkroman1/aai/runtime");
|
|
29
|
+
expect(Object.keys(mod).sort()).toMatchSnapshot();
|
|
30
|
+
});
|
|
31
|
+
});
|
package/sdk/manifest-barrel.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// Copyright 2025 the AAI authors. MIT license.
|
|
2
2
|
/**
|
|
3
|
-
* Manifest barrel — agent
|
|
3
|
+
* Manifest barrel — agent config conversion and tool schema handling.
|
|
4
4
|
*
|
|
5
|
-
* Used by aai-cli (
|
|
5
|
+
* Used by aai-cli (bundler) and aai-server (rpc-schemas).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
export {
|
|
9
|
+
type AgentConfig,
|
|
10
|
+
AgentConfigSchema,
|
|
11
|
+
type AgentConfigSource,
|
|
12
|
+
agentToolsToSchemas,
|
|
13
|
+
EMPTY_PARAMS,
|
|
14
|
+
type ExecuteTool,
|
|
15
|
+
type ToolSchema,
|
|
16
|
+
ToolSchemaSchema,
|
|
17
|
+
toAgentConfig,
|
|
18
|
+
} from "./_internal-types.ts";
|
package/sdk/manifest.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
import { describe, expect, expectTypeOf, test } from "vitest";
|
|
4
|
+
import { type Manifest, parseManifest } from "./manifest.ts";
|
|
5
|
+
import type { AgentConfig, ToolSchema } from "./manifest-barrel.ts";
|
|
6
|
+
import { agentToolsToSchemas, toAgentConfig } from "./manifest-barrel.ts";
|
|
4
7
|
|
|
5
8
|
describe("parseManifest", () => {
|
|
6
9
|
test("minimal manifest requires only name", () => {
|
|
@@ -12,6 +15,7 @@ describe("parseManifest", () => {
|
|
|
12
15
|
maxSteps: 5,
|
|
13
16
|
toolChoice: "auto",
|
|
14
17
|
builtinTools: [],
|
|
18
|
+
allowedHosts: [],
|
|
15
19
|
tools: {},
|
|
16
20
|
});
|
|
17
21
|
});
|
|
@@ -53,4 +57,101 @@ describe("parseManifest", () => {
|
|
|
53
57
|
test("rejects unknown builtinTools", () => {
|
|
54
58
|
expect(() => parseManifest({ name: "X", builtinTools: ["not_a_tool"] })).toThrow();
|
|
55
59
|
});
|
|
60
|
+
|
|
61
|
+
test("allowedHosts defaults to empty array when omitted", () => {
|
|
62
|
+
const result = parseManifest({ name: "test" });
|
|
63
|
+
expect(result.allowedHosts).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("allowedHosts passes through valid patterns", () => {
|
|
67
|
+
const result = parseManifest({
|
|
68
|
+
name: "test",
|
|
69
|
+
allowedHosts: ["api.weather.com", "*.mycompany.com"],
|
|
70
|
+
});
|
|
71
|
+
expect(result.allowedHosts).toEqual(["api.weather.com", "*.mycompany.com"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("rejects invalid allowedHosts pattern", () => {
|
|
75
|
+
expect(() => parseManifest({ name: "test", allowedHosts: ["*"] })).toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects allowedHosts with IP address", () => {
|
|
79
|
+
expect(() => parseManifest({ name: "test", allowedHosts: ["192.168.1.1"] })).toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("rejects allowedHosts with private TLD", () => {
|
|
83
|
+
expect(() => parseManifest({ name: "test", allowedHosts: ["*.internal"] })).toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects allowedHosts with protocol", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
parseManifest({ name: "test", allowedHosts: ["https://api.example.com"] }),
|
|
89
|
+
).toThrow();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Property-based tests ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("property: parseManifest", () => {
|
|
96
|
+
test("valid manifests always parse", () => {
|
|
97
|
+
const validManifestArb = fc.record({
|
|
98
|
+
name: fc.string({ minLength: 1 }),
|
|
99
|
+
systemPrompt: fc.option(fc.string(), { nil: undefined }),
|
|
100
|
+
greeting: fc.option(fc.string(), { nil: undefined }),
|
|
101
|
+
maxSteps: fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined }),
|
|
102
|
+
toolChoice: fc.option(fc.constantFrom("auto" as const, "required" as const), {
|
|
103
|
+
nil: undefined,
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
fc.assert(
|
|
108
|
+
fc.property(validManifestArb, (manifest) => {
|
|
109
|
+
const result = parseManifest(manifest);
|
|
110
|
+
expect(result.name).toBe(manifest.name);
|
|
111
|
+
expect(result.maxSteps).toBeGreaterThan(0);
|
|
112
|
+
expect(["auto", "required"]).toContain(result.toolChoice);
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("missing name throws", () => {
|
|
118
|
+
// Generate objects that never have a `name` field
|
|
119
|
+
const noNameArb = fc.record({
|
|
120
|
+
systemPrompt: fc.option(fc.string(), { nil: undefined }),
|
|
121
|
+
greeting: fc.option(fc.string(), { nil: undefined }),
|
|
122
|
+
maxSteps: fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined }),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
fc.assert(
|
|
126
|
+
fc.property(noNameArb, (obj) => {
|
|
127
|
+
expect(() => parseManifest(obj)).toThrow();
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("manifest type contracts", () => {
|
|
134
|
+
test("parseManifest returns Manifest", () => {
|
|
135
|
+
const result = parseManifest({ name: "test" });
|
|
136
|
+
expectTypeOf(result).toEqualTypeOf<Manifest>();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("parseManifest accepts unknown input", () => {
|
|
140
|
+
expectTypeOf(parseManifest).parameter(0).toBeUnknown();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("toAgentConfig returns AgentConfig", () => {
|
|
144
|
+
const config = toAgentConfig({ name: "test", systemPrompt: "p", greeting: "g" });
|
|
145
|
+
expectTypeOf(config).toEqualTypeOf<AgentConfig>();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("agentToolsToSchemas returns ToolSchema[]", () => {
|
|
149
|
+
const schemas = agentToolsToSchemas({});
|
|
150
|
+
expectTypeOf(schemas).toEqualTypeOf<ToolSchema[]>();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("Manifest has allowedHosts as string[]", () => {
|
|
154
|
+
const result = parseManifest({ name: "test" });
|
|
155
|
+
expectTypeOf(result.allowedHosts).toEqualTypeOf<string[]>();
|
|
156
|
+
});
|
|
56
157
|
});
|
package/sdk/manifest.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { validateAllowedHostPattern } from "./allowed-hosts.ts";
|
|
10
11
|
import { BuiltinToolSchema, DEFAULT_GREETING, DEFAULT_SYSTEM_PROMPT } from "./types.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -43,6 +44,8 @@ export type Manifest = {
|
|
|
43
44
|
theme?: Record<string, string> | undefined;
|
|
44
45
|
/** Custom tool definitions keyed by tool name. */
|
|
45
46
|
tools: Record<string, ToolManifest>;
|
|
47
|
+
/** Hostnames the agent is allowed to fetch. Empty = no fetch access. */
|
|
48
|
+
allowedHosts: string[];
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
const ToolManifestSchema = z.object({
|
|
@@ -61,6 +64,21 @@ const ManifestSchema = z.object({
|
|
|
61
64
|
idleTimeoutMs: z.number().int().positive().optional(),
|
|
62
65
|
theme: z.record(z.string(), z.string()).optional(),
|
|
63
66
|
tools: z.record(z.string(), ToolManifestSchema).optional(),
|
|
67
|
+
allowedHosts: z
|
|
68
|
+
.array(z.string())
|
|
69
|
+
.optional()
|
|
70
|
+
.superRefine((hosts, ctx) => {
|
|
71
|
+
if (!hosts) return;
|
|
72
|
+
for (const h of hosts) {
|
|
73
|
+
const result = validateAllowedHostPattern(h);
|
|
74
|
+
if (!result.valid) {
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: z.ZodIssueCode.custom,
|
|
77
|
+
message: `Invalid allowedHosts pattern "${h}": ${result.reason}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
64
82
|
});
|
|
65
83
|
|
|
66
84
|
/**
|
|
@@ -85,5 +103,6 @@ export function parseManifest(input: unknown): Manifest {
|
|
|
85
103
|
idleTimeoutMs: parsed.idleTimeoutMs,
|
|
86
104
|
theme: parsed.theme,
|
|
87
105
|
tools: parsed.tools ?? {},
|
|
106
|
+
allowedHosts: parsed.allowedHosts ?? [],
|
|
88
107
|
};
|
|
89
108
|
}
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
MAX_TOOL_RESULT_CHARS,
|
|
16
16
|
} from "./constants.ts";
|
|
17
17
|
import {
|
|
18
|
-
AUDIO_FORMAT,
|
|
19
18
|
ClientMessageSchema,
|
|
20
19
|
KvRequestSchema,
|
|
21
20
|
ServerMessageSchema,
|
|
@@ -35,7 +34,6 @@ type Fixture = {
|
|
|
35
34
|
ClientMessage: Record<string, unknown>[];
|
|
36
35
|
KvRequest: Record<string, unknown>[];
|
|
37
36
|
constants: {
|
|
38
|
-
AUDIO_FORMAT: string;
|
|
39
37
|
DEFAULT_STT_SAMPLE_RATE: number;
|
|
40
38
|
DEFAULT_TTS_SAMPLE_RATE: number;
|
|
41
39
|
MAX_TOOL_RESULT_CHARS: number;
|
|
@@ -161,10 +159,6 @@ describe.each(fixtureFiles)("compat fixture: %s", (filename) => {
|
|
|
161
159
|
// ── Constants stability ─────────────────────────────────────────
|
|
162
160
|
|
|
163
161
|
describe("constants stability", () => {
|
|
164
|
-
test("AUDIO_FORMAT unchanged", () => {
|
|
165
|
-
expect(AUDIO_FORMAT).toBe(fixture.constants.AUDIO_FORMAT);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
162
|
test("DEFAULT_STT_SAMPLE_RATE unchanged", () => {
|
|
169
163
|
expect(DEFAULT_STT_SAMPLE_RATE).toBe(fixture.constants.DEFAULT_STT_SAMPLE_RATE);
|
|
170
164
|
});
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
} from "./constants.ts";
|
|
15
15
|
import type { ClientEvent, ClientMessage, ServerMessage } from "./protocol.ts";
|
|
16
16
|
import {
|
|
17
|
-
AUDIO_FORMAT,
|
|
18
17
|
ClientEventSchema,
|
|
19
18
|
ClientMessageSchema,
|
|
20
19
|
KvRequestSchema,
|
|
@@ -24,10 +23,6 @@ import {
|
|
|
24
23
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
25
24
|
|
|
26
25
|
describe("protocol constants", () => {
|
|
27
|
-
test("audio format", () => {
|
|
28
|
-
expect(AUDIO_FORMAT).toMatchInlineSnapshot(`"pcm16"`);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
26
|
test("sample rates", () => {
|
|
32
27
|
expect(DEFAULT_STT_SAMPLE_RATE).toMatchInlineSnapshot("16000");
|
|
33
28
|
expect(DEFAULT_TTS_SAMPLE_RATE).toMatchInlineSnapshot("24000");
|
|
@@ -77,6 +72,7 @@ describe("server→client event wire format", () => {
|
|
|
77
72
|
["reset", { type: "reset" }],
|
|
78
73
|
["idle_timeout", { type: "idle_timeout" }],
|
|
79
74
|
["error", { type: "error", code: "stt", message: "Speech recognition failed" }],
|
|
75
|
+
["custom_event", { type: "custom_event", event: "game_state", data: { hp: 10 } }],
|
|
80
76
|
];
|
|
81
77
|
|
|
82
78
|
test.each(valid)("%s parses successfully", (_label, event) => {
|
|
@@ -94,6 +90,12 @@ describe("server→client event wire format", () => {
|
|
|
94
90
|
).toBe(false);
|
|
95
91
|
});
|
|
96
92
|
|
|
93
|
+
test("rejects custom_event with empty event name", () => {
|
|
94
|
+
expect(
|
|
95
|
+
ClientEventSchema.safeParse({ type: "custom_event", event: "", data: null }).success,
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
97
99
|
test("rejects tool_call_done with oversized result", () => {
|
|
98
100
|
expect(
|
|
99
101
|
ClientEventSchema.safeParse({
|