@frockbot/plugin-web 0.0.0 → 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/README.md +71 -1
- package/frockbot.json +26 -0
- package/package.json +31 -6
- package/src/agent.test.ts +265 -0
- package/src/agent.ts +504 -0
- package/src/contract.ts +218 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +3 -0
- package/src/ssrf.test.ts +174 -0
- package/src/ssrf.ts +318 -0
- package/tsconfig.json +15 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The provider-neutral web-search contract.
|
|
2
|
+
//
|
|
3
|
+
// The register (`docs/research/grokbot-computer.md:564`, row 47) names web
|
|
4
|
+
// search as a first-class tool but records no schema, no bound, and no error
|
|
5
|
+
// shape for it: none was ever measured. Everything here is FrockBot's own
|
|
6
|
+
// contract, defined from first principles, and it is deliberately narrower
|
|
7
|
+
// than any one provider's API so a second provider can satisfy it unchanged —
|
|
8
|
+
// the two-provider check the constitution applies to the model interface.
|
|
9
|
+
//
|
|
10
|
+
// This module holds no transport. `plugin-provider-ollama-cloud` implements
|
|
11
|
+
// {@link WebSearchV1} over `POST {apiBaseUrl}/api/web_search`; this Package
|
|
12
|
+
// never imports it.
|
|
13
|
+
import type { ToolDefinition, ToolSchema } from "@frockbot/kernel-contracts";
|
|
14
|
+
|
|
15
|
+
export const WEB_SEARCH_TOOL_NAME_V1 = "web_search";
|
|
16
|
+
|
|
17
|
+
/** Input bounds. A query longer than this is a paste, not a search. */
|
|
18
|
+
export const WEB_SEARCH_MAX_QUERY_LENGTH_V1 = 400;
|
|
19
|
+
export const WEB_SEARCH_MIN_RESULTS_V1 = 1;
|
|
20
|
+
export const WEB_SEARCH_MAX_RESULTS_V1 = 10;
|
|
21
|
+
export const WEB_SEARCH_DEFAULT_RESULTS_V1 = 5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Output bounds. A snippet is an orientation aid, not the page: a Bot that
|
|
25
|
+
* wants the page calls `web_fetch`. Truncating here keeps one search from
|
|
26
|
+
* spending a Turn's context, and keeps the durable `tool/result` small.
|
|
27
|
+
*/
|
|
28
|
+
export const WEB_SEARCH_MAX_SNIPPET_LENGTH_V1 = 1000;
|
|
29
|
+
|
|
30
|
+
export interface WebSearchResultV1 {
|
|
31
|
+
title: string;
|
|
32
|
+
url: string;
|
|
33
|
+
snippet: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WebSearchResponseV1 {
|
|
37
|
+
query: string;
|
|
38
|
+
results: WebSearchResultV1[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WebSearchRequestV1 {
|
|
42
|
+
query: string;
|
|
43
|
+
maxResults: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What one search runs under: the durable effect identity of the tool call and
|
|
48
|
+
* its cancellation signal. Both are kernel vocabulary, not provider
|
|
49
|
+
* vocabulary — a provider that needs a per-call credential lease keys it on
|
|
50
|
+
* `effectId`, and one that needs neither ignores both.
|
|
51
|
+
*/
|
|
52
|
+
export interface WebSearchExecutionV1 {
|
|
53
|
+
effectId: string;
|
|
54
|
+
signal: AbortSignal;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The narrow interface a search provider Package implements. */
|
|
58
|
+
export interface WebSearchV1 {
|
|
59
|
+
search(
|
|
60
|
+
request: WebSearchRequestV1,
|
|
61
|
+
execution: WebSearchExecutionV1,
|
|
62
|
+
): Promise<WebSearchResponseV1>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const WEB_SEARCH_INPUT_SCHEMA_V1: ToolSchema["inputSchema"] = {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
query: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "What to search the public web for.",
|
|
71
|
+
minLength: 1,
|
|
72
|
+
maxLength: WEB_SEARCH_MAX_QUERY_LENGTH_V1,
|
|
73
|
+
},
|
|
74
|
+
max_results: {
|
|
75
|
+
type: "integer",
|
|
76
|
+
description: `How many results to return, ${WEB_SEARCH_MIN_RESULTS_V1}–${WEB_SEARCH_MAX_RESULTS_V1}. Defaults to ${WEB_SEARCH_DEFAULT_RESULTS_V1}.`,
|
|
77
|
+
minimum: WEB_SEARCH_MIN_RESULTS_V1,
|
|
78
|
+
maximum: WEB_SEARCH_MAX_RESULTS_V1,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ["query"],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Decode the model's arguments at the tool seam. Throws with a plain reason. */
|
|
90
|
+
export function decodeWebSearchInputV1(input: unknown): WebSearchRequestV1 {
|
|
91
|
+
if (!isRecord(input)) throw new Error("web_search input must be an object");
|
|
92
|
+
const query = typeof input.query === "string" ? input.query.trim() : "";
|
|
93
|
+
if (query.length === 0 || query.length > WEB_SEARCH_MAX_QUERY_LENGTH_V1) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`web_search query must be 1–${WEB_SEARCH_MAX_QUERY_LENGTH_V1} characters`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const requested = input.max_results ?? WEB_SEARCH_DEFAULT_RESULTS_V1;
|
|
99
|
+
if (
|
|
100
|
+
typeof requested !== "number" ||
|
|
101
|
+
!Number.isSafeInteger(requested) ||
|
|
102
|
+
requested < WEB_SEARCH_MIN_RESULTS_V1 ||
|
|
103
|
+
requested > WEB_SEARCH_MAX_RESULTS_V1
|
|
104
|
+
) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`web_search max_results must be an integer ${WEB_SEARCH_MIN_RESULTS_V1}–${WEB_SEARCH_MAX_RESULTS_V1}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return { query, maxResults: requested };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function boundedSnippet(value: unknown): string {
|
|
113
|
+
const text =
|
|
114
|
+
typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
115
|
+
return text.slice(0, WEB_SEARCH_MAX_SNIPPET_LENGTH_V1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decode a provider's answer at the seam it crosses. Anything the provider
|
|
120
|
+
* adds is dropped, a result with no usable `url` is dropped, and the list is
|
|
121
|
+
* trimmed to what was asked for — the model never sees a provider's shape.
|
|
122
|
+
*/
|
|
123
|
+
export function decodeWebSearchResponseV1(
|
|
124
|
+
value: unknown,
|
|
125
|
+
request: WebSearchRequestV1,
|
|
126
|
+
): WebSearchResponseV1 {
|
|
127
|
+
if (!isRecord(value)) throw new Error("web search response is invalid");
|
|
128
|
+
const rows = value.results;
|
|
129
|
+
if (!Array.isArray(rows)) throw new Error("web search response is invalid");
|
|
130
|
+
const results: WebSearchResultV1[] = [];
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
if (results.length >= request.maxResults) break;
|
|
133
|
+
if (!isRecord(row)) continue;
|
|
134
|
+
const url = typeof row.url === "string" ? row.url.trim() : "";
|
|
135
|
+
if (url.length === 0 || url.length > 2048) continue;
|
|
136
|
+
const title = typeof row.title === "string" ? row.title.trim() : "";
|
|
137
|
+
results.push({
|
|
138
|
+
title: title.slice(0, 300),
|
|
139
|
+
url,
|
|
140
|
+
// Ollama names the field `content`; the contract names it `snippet`.
|
|
141
|
+
snippet: boundedSnippet(row.snippet ?? row.content ?? row.description),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { query: request.query, results };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The durable `tool/result` body. The kernel event carries `content: string`
|
|
149
|
+
* (`kernel-contracts/src/types.ts`), so every tool emits stable JSON rather
|
|
150
|
+
* than prose: a later reader parses it instead of re-reading a sentence.
|
|
151
|
+
*/
|
|
152
|
+
export function encodeWebSearchResultV1(response: WebSearchResponseV1): string {
|
|
153
|
+
return JSON.stringify(response);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the `web_search` tool over any {@link WebSearchV1}. The definition,
|
|
158
|
+
* its bounds, and its durable result shape live here so a second provider
|
|
159
|
+
* Package contributes the same tool by supplying transport alone.
|
|
160
|
+
*
|
|
161
|
+
* `idempotent: true`: a search is read-only, so recovery after eviction
|
|
162
|
+
* re-runs it rather than reconciling a recorded effect.
|
|
163
|
+
*/
|
|
164
|
+
export function createWebSearchToolDefinitionV1(
|
|
165
|
+
provider: WebSearchV1,
|
|
166
|
+
): ToolDefinition {
|
|
167
|
+
return {
|
|
168
|
+
name: WEB_SEARCH_TOOL_NAME_V1,
|
|
169
|
+
// A general work tool: the reach an `executor` subagent has, and not the
|
|
170
|
+
// narrow reach of `browserUse`, `computerUse`, or the two video roles.
|
|
171
|
+
admission: { subagentRoles: ["executor"] },
|
|
172
|
+
description:
|
|
173
|
+
"Search the public web and return titles, URLs and short snippets. Use web_fetch to read a result in full.",
|
|
174
|
+
inputSchema: WEB_SEARCH_INPUT_SCHEMA_V1,
|
|
175
|
+
idempotent: true,
|
|
176
|
+
validate: (input: unknown) => {
|
|
177
|
+
try {
|
|
178
|
+
decodeWebSearchInputV1(input);
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
execute: async (input, context) => {
|
|
185
|
+
let request: WebSearchRequestV1;
|
|
186
|
+
try {
|
|
187
|
+
request = decodeWebSearchInputV1(input);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
content: JSON.stringify({
|
|
191
|
+
error: "web-search-invalid-input",
|
|
192
|
+
message: error instanceof Error ? error.message : "invalid input",
|
|
193
|
+
}),
|
|
194
|
+
isError: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const response = await provider.search(request, {
|
|
199
|
+
effectId: context.effectId,
|
|
200
|
+
signal: context.signal,
|
|
201
|
+
});
|
|
202
|
+
return { content: encodeWebSearchResultV1(response), isError: false };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return {
|
|
205
|
+
content: JSON.stringify({
|
|
206
|
+
error: "web-search-failed",
|
|
207
|
+
query: request.query,
|
|
208
|
+
message: (error instanceof Error
|
|
209
|
+
? error.message
|
|
210
|
+
: "web search failed"
|
|
211
|
+
).slice(0, 500),
|
|
212
|
+
}),
|
|
213
|
+
isError: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
package/src/ssrf.test.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
classifyOutboundUrlV1,
|
|
4
|
+
classifyWebFetchUrlV1,
|
|
5
|
+
parseIpv4LiteralV1,
|
|
6
|
+
parseIpv6LiteralV1,
|
|
7
|
+
type SsrfRefusalReasonV1,
|
|
8
|
+
} from "./ssrf.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The classifier's whole contract, as a table. Each row is a URL a model could
|
|
12
|
+
* plausibly hand `web_fetch` and the verdict the Bot's outbound boundary must
|
|
13
|
+
* reach. `undefined` means the URL is allowed.
|
|
14
|
+
*/
|
|
15
|
+
const TABLE: ReadonlyArray<[string, SsrfRefusalReasonV1 | undefined]> = [
|
|
16
|
+
// Allowed: ordinary public sites, in the shapes they actually arrive in.
|
|
17
|
+
["https://example.com/", undefined],
|
|
18
|
+
["https://example.com:443/a/b?c=d#e", undefined],
|
|
19
|
+
["https://sub.example.co.uk/page", undefined],
|
|
20
|
+
["https://example.test/index.html", undefined],
|
|
21
|
+
["https://1.1.1.1/", undefined],
|
|
22
|
+
["https://8.8.8.8/resolve", undefined],
|
|
23
|
+
["https://[2606:4700:4700::1111]/", undefined],
|
|
24
|
+
["https://xn--bcher-kva.example/", undefined],
|
|
25
|
+
|
|
26
|
+
// Rule 1: scheme.
|
|
27
|
+
["http://example.com/", "ssrf-blocked-scheme"],
|
|
28
|
+
["http://169.254.169.254/latest/meta-data", "ssrf-blocked-scheme"],
|
|
29
|
+
["ftp://example.com/", "ssrf-blocked-scheme"],
|
|
30
|
+
["file:///etc/passwd", "ssrf-blocked-scheme"],
|
|
31
|
+
["data:text/html,<b>hi</b>", "ssrf-blocked-scheme"],
|
|
32
|
+
["blob:https://example.com/abc", "ssrf-blocked-scheme"],
|
|
33
|
+
|
|
34
|
+
// Rule 2: port.
|
|
35
|
+
["https://example.com:8080/", "ssrf-blocked-port"],
|
|
36
|
+
["https://example.com:22/", "ssrf-blocked-port"],
|
|
37
|
+
|
|
38
|
+
// Rule 5: credentials in the URL.
|
|
39
|
+
["https://user:pass@example.com/", "ssrf-blocked-credentials"],
|
|
40
|
+
["https://user@example.com/", "ssrf-blocked-credentials"],
|
|
41
|
+
|
|
42
|
+
// Rule 3: names that are not the public internet.
|
|
43
|
+
["https://localhost/", "ssrf-blocked-host"],
|
|
44
|
+
["https://server.local/", "ssrf-blocked-host"],
|
|
45
|
+
["https://printer.home.arpa/", "ssrf-blocked-host"],
|
|
46
|
+
["https://api.localhost/", "ssrf-blocked-host"],
|
|
47
|
+
["https://metadata.google.internal/computeMetadata/v1/", "ssrf-blocked-host"],
|
|
48
|
+
["https://foo.internal/", "ssrf-blocked-host"],
|
|
49
|
+
["https://redis/", "ssrf-blocked-host"],
|
|
50
|
+
["https://metadata/", "ssrf-blocked-host"],
|
|
51
|
+
|
|
52
|
+
// Rule 4: IPv4, in every encoding the host parser accepts.
|
|
53
|
+
["https://169.254.169.254/latest/meta-data", "ssrf-blocked-private-address"],
|
|
54
|
+
["https://127.0.0.1/", "ssrf-blocked-private-address"],
|
|
55
|
+
["https://0177.0.0.1/", "ssrf-blocked-private-address"],
|
|
56
|
+
["https://2130706433/", "ssrf-blocked-private-address"],
|
|
57
|
+
["https://0x7f000001/", "ssrf-blocked-private-address"],
|
|
58
|
+
["https://0x7f.1/", "ssrf-blocked-private-address"],
|
|
59
|
+
["https://127.1/", "ssrf-blocked-private-address"],
|
|
60
|
+
["https://10.0.0.5/", "ssrf-blocked-private-address"],
|
|
61
|
+
["https://172.16.0.1/", "ssrf-blocked-private-address"],
|
|
62
|
+
["https://192.168.1.1/", "ssrf-blocked-private-address"],
|
|
63
|
+
["https://100.64.0.1/", "ssrf-blocked-private-address"],
|
|
64
|
+
["https://198.18.0.1/", "ssrf-blocked-private-address"],
|
|
65
|
+
["https://192.0.0.1/", "ssrf-blocked-private-address"],
|
|
66
|
+
["https://0.0.0.0/", "ssrf-blocked-private-address"],
|
|
67
|
+
["https://255.255.255.255/", "ssrf-blocked-private-address"],
|
|
68
|
+
["https://239.0.0.1/", "ssrf-blocked-private-address"],
|
|
69
|
+
|
|
70
|
+
// Rule 4: IPv6, including the mapped and compatible forms.
|
|
71
|
+
["https://[::1]/", "ssrf-blocked-private-address"],
|
|
72
|
+
["https://[::]/", "ssrf-blocked-private-address"],
|
|
73
|
+
["https://[::ffff:127.0.0.1]/", "ssrf-blocked-private-address"],
|
|
74
|
+
["https://[::ffff:8.8.8.8]/", "ssrf-blocked-private-address"],
|
|
75
|
+
["https://[fd00::1]/", "ssrf-blocked-private-address"],
|
|
76
|
+
["https://[fe80::1]/", "ssrf-blocked-private-address"],
|
|
77
|
+
["https://[ff02::1]/", "ssrf-blocked-private-address"],
|
|
78
|
+
|
|
79
|
+
// Not URLs at all.
|
|
80
|
+
["", "ssrf-invalid-url"],
|
|
81
|
+
["not a url", "ssrf-invalid-url"],
|
|
82
|
+
["/relative/path", "ssrf-invalid-url"],
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
describe("the web_fetch outbound classifier", () => {
|
|
86
|
+
for (const [candidate, expected] of TABLE) {
|
|
87
|
+
test(`${expected ?? "allows"} ${candidate || "(empty)"}`, () => {
|
|
88
|
+
const verdict = classifyWebFetchUrlV1(candidate);
|
|
89
|
+
if (expected === undefined) {
|
|
90
|
+
expect({ candidate, allowed: verdict.allowed }).toEqual({
|
|
91
|
+
candidate,
|
|
92
|
+
allowed: true,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
expect({
|
|
97
|
+
candidate,
|
|
98
|
+
reason: verdict.allowed ? undefined : verdict.reason,
|
|
99
|
+
}).toEqual({ candidate, reason: expected });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test("refuses a redirect that leaves the public internet", () => {
|
|
104
|
+
// The first hop is a legitimate public URL; the `Location` is not. Only
|
|
105
|
+
// re-running the classifier on the resolved target catches this, which is
|
|
106
|
+
// exactly what `web_fetch` does on every hop.
|
|
107
|
+
expect(classifyWebFetchUrlV1("https://redirector.example/go").allowed).toBe(
|
|
108
|
+
true,
|
|
109
|
+
);
|
|
110
|
+
const followed = classifyWebFetchUrlV1(
|
|
111
|
+
new URL(
|
|
112
|
+
"//169.254.169.254/latest/meta-data",
|
|
113
|
+
"https://redirector.example/go",
|
|
114
|
+
).toString(),
|
|
115
|
+
);
|
|
116
|
+
expect(followed.allowed ? "allowed" : followed.reason).toBe(
|
|
117
|
+
"ssrf-blocked-private-address",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("lets a caller that legitimately needs a port opt into one", () => {
|
|
122
|
+
// `web_fetch` refuses a non-default port; a User-named MCP endpoint on its
|
|
123
|
+
// own port is an ordinary deployment, so that caller opts in explicitly.
|
|
124
|
+
expect(
|
|
125
|
+
classifyWebFetchUrlV1("https://mcp.example.test:8443/mcp").allowed,
|
|
126
|
+
).toBe(false);
|
|
127
|
+
expect(
|
|
128
|
+
classifyOutboundUrlV1("https://mcp.example.test:8443/mcp", {
|
|
129
|
+
allowNonDefaultPort: true,
|
|
130
|
+
}).allowed,
|
|
131
|
+
).toBe(true);
|
|
132
|
+
// Opting into a port opts into nothing else.
|
|
133
|
+
const stillPrivate = classifyOutboundUrlV1("https://127.0.0.1:8443/", {
|
|
134
|
+
allowNonDefaultPort: true,
|
|
135
|
+
});
|
|
136
|
+
expect(stillPrivate.allowed ? "allowed" : stillPrivate.reason).toBe(
|
|
137
|
+
"ssrf-blocked-private-address",
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("never names a resolved address in a refusal", () => {
|
|
142
|
+
const verdict = classifyWebFetchUrlV1("https://169.254.169.254/");
|
|
143
|
+
expect(verdict.allowed).toBe(false);
|
|
144
|
+
if (verdict.allowed) return;
|
|
145
|
+
expect(verdict.message).not.toContain("169.254");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("normalizes every IPv4 encoding to the same address", () => {
|
|
149
|
+
for (const candidate of [
|
|
150
|
+
"127.0.0.1",
|
|
151
|
+
"0177.0.0.1",
|
|
152
|
+
"2130706433",
|
|
153
|
+
"0x7f000001",
|
|
154
|
+
"127.1",
|
|
155
|
+
]) {
|
|
156
|
+
expect({ candidate, octets: parseIpv4LiteralV1(candidate) }).toEqual({
|
|
157
|
+
candidate,
|
|
158
|
+
octets: [127, 0, 0, 1],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
expect(parseIpv4LiteralV1("example.com")).toBeUndefined();
|
|
162
|
+
expect(parseIpv4LiteralV1("256.0.0.1")).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("expands compressed and v4-embedded IPv6 literals", () => {
|
|
166
|
+
expect(parseIpv6LiteralV1("[::1]")).toEqual([
|
|
167
|
+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
|
|
168
|
+
]);
|
|
169
|
+
expect(parseIpv6LiteralV1("::ffff:127.0.0.1")).toEqual([
|
|
170
|
+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 127, 0, 0, 1,
|
|
171
|
+
]);
|
|
172
|
+
expect(parseIpv6LiteralV1("example.com")).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|