@jini-ai/core 0.6.10 → 0.6.12
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/CHANGELOG.md +9 -0
- package/dist/redact.js +24 -1
- package/dist/settings/agent-tools.js +9 -6
- package/dist/settings/tool-registrations.js +9 -3
- package/dist/tool-input-normalization.d.ts +4 -3
- package/dist/tool-input-normalization.js +48 -10
- package/dist/tool-registry.d.ts +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @jini-ai/core
|
|
2
2
|
|
|
3
|
+
## 0.6.12
|
|
4
|
+
|
|
5
|
+
## 0.6.11
|
|
6
|
+
|
|
7
|
+
### Patch Changes
|
|
8
|
+
|
|
9
|
+
- OAuth accepts a parent protected-resource identifier and forwards the refresh resource; settings definitions are filtered; scope selectors are never id shorthand; secret-named URL parameters are masked by default in redaction.
|
|
10
|
+
- Tool inputs apply a sole near-miss and declared input aliases; plugin-load outcomes are reported; `web_fetch` reads SVG/JS as text; `content_read` suggests its resource first; the browser falls back to system Chrome.
|
|
11
|
+
|
|
3
12
|
## 0.6.10
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
package/dist/redact.js
CHANGED
|
@@ -181,7 +181,8 @@ function isLuhnValid(digits) {
|
|
|
181
181
|
// correlation ID uses uppercase hex and is meant to reach the model and be quoted to an
|
|
182
182
|
// operator; delegated-tool-calls is the route segment named in delegated tool failures.
|
|
183
183
|
// Matching the ENTIRE run keeps a secret glued onto either spelling redactable.
|
|
184
|
-
|
|
184
|
+
const SECRET_LABELS = 'authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie';
|
|
185
|
+
export const LABELED_SECRET_RE = new RegExp(`\\b(${SECRET_LABELS})\\b\\s*[:=]\\s*\\S+`, 'gi');
|
|
185
186
|
export const OPAQUE_TOKEN_RE = /[A-Za-z0-9_-]{20,}/g;
|
|
186
187
|
export const NON_SECRET_TOKEN_RE = /^(?:ERR-[0-9A-F]{4}(?:-[0-9A-F]{4}){3}|delegated-tool-calls|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)$/;
|
|
187
188
|
// Runtime markers must survive a second pass; the bounded sanitizer deliberately retains
|
|
@@ -198,6 +199,15 @@ const PROVIDER_API_KEY_QUERY = /([?&]key=)([^&\s]+)/gi;
|
|
|
198
199
|
// readable for debugging without exposing credentials that authorize access to the resource.
|
|
199
200
|
const URL_CREDENTIALS_RE = /(\b[a-z][a-z0-9+.-]*:\/\/)([^/\s?#@]+)@/gi;
|
|
200
201
|
const SIGNED_QUERY_RE = /([?&](?:signature|sig|x-amz-signature|x-goog-signature|access_token|refresh_token)=)([^&#\s,;"']+)/gi;
|
|
202
|
+
// A secret word must END a name segment (split at . _ - or the name's end): api_key, apiKey,
|
|
203
|
+
// authtoken, X-Amz-Signature and custom.Token-value are masked, while author, keyword, design,
|
|
204
|
+
// signup and page stay readable. Names that merely end in a secret word (zipcode, error_code,
|
|
205
|
+
// monkey) are masked too; over-masking a URL value is the cheaper failure. Empty values stay.
|
|
206
|
+
// Match through a trailing fragment; only '&', quotes, parentheses or whitespace end the value.
|
|
207
|
+
// Both name runs are bounded so a long separator-free run costs linear time, not quadratic.
|
|
208
|
+
const URL_SECRET_PARAM_RE = /[?&#;]([\w.-]{0,128}?(?:token|key|secret|signature|sig|code|password|passwd|pwd|auth|authorization|credential)s?(?=[._=-])[\w.-]{0,128})=[^&\s"'<>()]+/gi;
|
|
209
|
+
// A parameter the labeled rule already names (password, client_secret, ...) keeps that category.
|
|
210
|
+
const LABELED_URL_PARAM_NAME_RE = new RegExp(`(?:^|[.-])(?:${SECRET_LABELS})$`, 'i');
|
|
201
211
|
// Only an entire marker is already masked. A secret glued onto marker-like text still needs
|
|
202
212
|
// masking; a substring check would preserve credential suffixes in echoed header/query values.
|
|
203
213
|
function isMaskedValue(value) {
|
|
@@ -301,6 +311,9 @@ function redactKeyedCredentials({ text, marker }) {
|
|
|
301
311
|
out = out.replace(API_KEY_QUERY, (match, prefix) => isMaskedValue(match.slice(prefix.length)) ? match : `${prefix}${marker('api_key_query')}`);
|
|
302
312
|
out = out.replace(SHORT_BEARER_RE, () => marker('bearer_token'));
|
|
303
313
|
out = out.replace(PROVIDER_API_KEY_HEADER, (match, name, value) => isMaskedValue(value) ? match : `${name}: ${marker('api_key_header')}`);
|
|
314
|
+
// Sweep other URL parameters before the general labeled rule can consume harmless
|
|
315
|
+
// neighboring parameters (`password=x&page=2` used to lose `&page=2`).
|
|
316
|
+
out = redactUrlSecretParams({ text: out, marker });
|
|
304
317
|
return out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, _label, value) => {
|
|
305
318
|
// Replace only the captured value, retaining labels even when no whitespace separates
|
|
306
319
|
// them. Glued credential suffixes fail the complete-marker check and remain maskable.
|
|
@@ -309,6 +322,16 @@ function redactKeyedCredentials({ text, marker }) {
|
|
|
309
322
|
return `${match.slice(0, -value.length)}${marker('labeled_secret')}`;
|
|
310
323
|
});
|
|
311
324
|
}
|
|
325
|
+
/** Secret-named URL query/fragment values; values already masked above keep their category. */
|
|
326
|
+
function redactUrlSecretParams({ text, marker }) {
|
|
327
|
+
return text.replace(URL_SECRET_PARAM_RE, (match, name) => {
|
|
328
|
+
const separator = match.indexOf('=') + 1;
|
|
329
|
+
if (isMaskedValue(match.slice(separator)))
|
|
330
|
+
return match;
|
|
331
|
+
const category = LABELED_URL_PARAM_NAME_RE.test(name) ? 'labeled_secret' : 'url_secret_param';
|
|
332
|
+
return `${match.slice(0, separator)}${marker(category)}`;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
312
335
|
/** Email/IPv4/phone rules, then Luhn-checked card candidates. */
|
|
313
336
|
function redactPii({ text, marker }) {
|
|
314
337
|
return applyPatterns({ text, marker, patterns: PII_PATTERNS }).replace(CARD_CANDIDATE, (match) => {
|
|
@@ -49,13 +49,16 @@ import { AGENT_WRITABLE_PREFERENCE_IDS, AGENT_WRITABLE_PREFERENCES } from "./age
|
|
|
49
49
|
* write entries this file documents but never wires (see file header) carry no schema at all,
|
|
50
50
|
* since one is never published for a tool the model never sees.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
const
|
|
52
|
+
* Optional filters narrow the caller's own (boot-wired) workspace's site-owned partition plus
|
|
53
|
+
* the shared platform partition; they never select a different workspace. */
|
|
54
|
+
const LIST_DEFINITIONS_SCHEMA = {
|
|
55
55
|
type: "object",
|
|
56
56
|
additionalProperties: false,
|
|
57
57
|
required: [],
|
|
58
|
-
properties: {
|
|
58
|
+
properties: {
|
|
59
|
+
search: { type: "string", description: "Case-insensitive text filter over the full 'namespace.key' name." },
|
|
60
|
+
namespace: { type: "string", description: "Only definitions in this exact namespace." },
|
|
61
|
+
},
|
|
59
62
|
};
|
|
60
63
|
/** Shared by `settings_get_effective`/`settings_get_raw`: naming a DIFFERENT principal than the
|
|
61
64
|
* caller requires `settings.user.read` (checked by the handler, not expressed in this schema). */
|
|
@@ -138,10 +141,10 @@ export function getSettingsAgentToolCatalog() {
|
|
|
138
141
|
return [
|
|
139
142
|
{
|
|
140
143
|
name: "settings_list_definitions",
|
|
141
|
-
description: "Lists
|
|
144
|
+
description: "Lists active setting definitions visible to this workspace (platform core/theme definitions plus this workspace's own site-owned definitions), optionally filtered by search text and exact namespace: namespace, key, owner kind, scope bitmask, status, version, schema, agentWritable and confirmationRequired. Use before writing a setting. Metadata only — never a value.",
|
|
142
145
|
sideEffects: "none",
|
|
143
146
|
authorization: { permission: "settings.read.definitions" },
|
|
144
|
-
inputSchema:
|
|
147
|
+
inputSchema: LIST_DEFINITIONS_SCHEMA,
|
|
145
148
|
},
|
|
146
149
|
{
|
|
147
150
|
name: "settings_get_effective",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { adaptLegacyAuthorize } from "../tool-permissions.js";
|
|
2
2
|
import { agentWriteRules, buildSettingsValueWriteHandler, toWriteDeps } from "./agent-value-write-tools.js";
|
|
3
3
|
import { findAgentWriteRule } from "./agent-write-denylist.js";
|
|
4
|
-
import { buildDomainRegistrations, indexCatalogById, optionalString, requireInputRecord,
|
|
4
|
+
import { buildDomainRegistrations, indexCatalogById, optionalString, requireInputRecord, requireString, withSchemaOnRejection } from "../registration-kit.js";
|
|
5
5
|
import { ToolInputError } from "../tool-registry.js";
|
|
6
6
|
import { requireToolPermission } from "../tool-permissions.js";
|
|
7
7
|
import { getSettingsAgentToolCatalog } from "./agent-tools.js";
|
|
@@ -140,15 +140,21 @@ export function buildSettingsRegistrations(routeDeps) {
|
|
|
140
140
|
settings_set_value: buildSettingsValueWriteHandler(routeDeps, "settings_set_value"),
|
|
141
141
|
settings_clear_value: buildSettingsValueWriteHandler(routeDeps, "settings_clear_value"),
|
|
142
142
|
settings_list_definitions: async (ctx) => {
|
|
143
|
-
|
|
143
|
+
const input = requireInputRecord({ input: ctx.input === undefined ? {} : ctx.input });
|
|
144
|
+
const search = optionalString({ input, key: "search" })?.toLowerCase();
|
|
145
|
+
const namespace = optionalString({ input, key: "namespace" });
|
|
144
146
|
await routeDeps.settingsReady;
|
|
145
147
|
await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: routeDeps.authorize }), workspaceId: routeDeps.workspaceId, principalId: ctx.principal.id, permission: "settings.read.definitions" }, { entityType: "setting-definition" });
|
|
146
148
|
const [platformDefs, siteDefs] = await Promise.all([
|
|
147
149
|
routeDeps.settingsRepo.listActiveDefinitions({ workspaceId: null }),
|
|
148
150
|
routeDeps.settingsRepo.listActiveDefinitions({ workspaceId: routeDeps.workspaceId }),
|
|
149
151
|
]);
|
|
152
|
+
// Filter only authorized workspace rows, before alias lookups; never search stored values.
|
|
153
|
+
const definitions = [...platformDefs, ...siteDefs]
|
|
154
|
+
.filter((def) => namespace === undefined || def.namespace === namespace)
|
|
155
|
+
.filter((def) => !search || `${def.namespace}.${def.key}`.toLowerCase().includes(search));
|
|
150
156
|
const rules = agentWriteRules(routeDeps);
|
|
151
|
-
const data = (await Promise.all(
|
|
157
|
+
const data = (await Promise.all(definitions.map((def) => describeDefinitionForAgent(routeDeps, rules, def))))
|
|
152
158
|
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
|
|
153
159
|
return { data };
|
|
154
160
|
},
|
|
@@ -8,8 +8,8 @@ export interface NormalizedToolInput {
|
|
|
8
8
|
export type ToolInputNormalizer = (required: {
|
|
9
9
|
input: Readonly<Record<string, unknown>>;
|
|
10
10
|
}, optional?: Record<string, never>) => NormalizedToolInput | Promise<NormalizedToolInput>;
|
|
11
|
-
/** Canonicalize one batch layer before merging;
|
|
12
|
-
* final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
11
|
+
/** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
|
|
12
|
+
* and domain hooks for final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
13
13
|
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
14
14
|
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
15
15
|
*/
|
|
@@ -20,7 +20,8 @@ export declare function canonicalizeToolInputKeys({ input, schema }: {
|
|
|
20
20
|
input: Record<string, unknown>;
|
|
21
21
|
notes: readonly string[];
|
|
22
22
|
};
|
|
23
|
-
/** Normalize before policy, confirmation and handler validation.
|
|
23
|
+
/** Normalize before policy, confirmation and handler validation. Schema properties may declare
|
|
24
|
+
* `aliases: ['search', 'query']`; domain hooks own transformations beyond declared key aliases.
|
|
24
25
|
* Never changes the caller's object or includes values in notes. Invalid/ambiguous input remains
|
|
25
26
|
* a ToolInputError; ordinary domain/schema validation still belongs to the registered handler.
|
|
26
27
|
* @complexity Two bounded schema walks plus the optional domain hook's cost.
|
|
@@ -15,11 +15,16 @@ function sameLengthNearMiss({ left, right, index }) {
|
|
|
15
15
|
return left.slice(index + 1) === right.slice(index + 1)
|
|
16
16
|
|| (left[index] === right[index + 1] && left[index + 1] === right[index] && left.slice(index + 2) === right.slice(index + 2));
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/** Scope selectors name a container, never the resource: guessing `workspaceId` as `id` (or the
|
|
19
|
+
* reverse) would silently drop or invent a scope. Only a declared name or alias may accept one. */
|
|
20
|
+
const SCOPE_SELECTORS = new Set(['workspaceid', 'siteid']);
|
|
21
|
+
/** Whether either key is the generic id shorthand for the other's id suffix; never for a scope selector. */
|
|
19
22
|
function isIdShorthand({ left, right }, _options = {}) {
|
|
23
|
+
if (SCOPE_SELECTORS.has(left) || SCOPE_SELECTORS.has(right))
|
|
24
|
+
return false;
|
|
20
25
|
return (left === 'id' && right.endsWith('id')) || (right === 'id' && left.endsWith('id'));
|
|
21
26
|
}
|
|
22
|
-
/** Detect id shorthand or one insertion, deletion, substitution or transposition
|
|
27
|
+
/** Detect id shorthand or one insertion, deletion, substitution or transposition. */
|
|
23
28
|
function nearMiss({ left, right }, _options = {}) {
|
|
24
29
|
if (left.length > 128 || right.length > 128)
|
|
25
30
|
return false;
|
|
@@ -53,6 +58,19 @@ function coerceString({ value, types, path, notes }) {
|
|
|
53
58
|
}
|
|
54
59
|
return coerceNumber({ value, types, path, notes });
|
|
55
60
|
}
|
|
61
|
+
/** Unwrap only a singleton string array; refuse malformed arrays without exposing values.
|
|
62
|
+
* Keys-only batch layers preserve values until the final merged input is normalized.
|
|
63
|
+
* @complexity O(t) time for t allowed schema types, O(1) space; array contents are not scanned.
|
|
64
|
+
*/
|
|
65
|
+
function unwrapStringArray({ value, types, step }) {
|
|
66
|
+
if (step.walk.keysOnly || !Array.isArray(value) || !types.includes('string'))
|
|
67
|
+
return value;
|
|
68
|
+
if (value.length !== 1 || typeof value[0] !== 'string') {
|
|
69
|
+
throw new ToolInputError({ message: `${step.path}: send one value as a string or a one-element string array.` });
|
|
70
|
+
}
|
|
71
|
+
step.walk.notes.push(`${step.path}: one-item string array→string`);
|
|
72
|
+
return value[0];
|
|
73
|
+
}
|
|
56
74
|
function normalizeArray({ value, schema, step }) {
|
|
57
75
|
const { path, depth, walk } = step;
|
|
58
76
|
let list = value;
|
|
@@ -65,12 +83,30 @@ function normalizeArray({ value, schema, step }) {
|
|
|
65
83
|
return list;
|
|
66
84
|
return list.map((item, index) => normalizeValue({ value: item, schema: schema.items, step: { path: `${path}[${index}]`, depth: depth + 1, walk } }));
|
|
67
85
|
}
|
|
68
|
-
/**
|
|
86
|
+
/** Match a canonical spelling or its property's declared string aliases, ignoring case/separators.
|
|
87
|
+
* Malformed alias annotations confer no alternate names; exact property names still win.
|
|
88
|
+
* @complexity O(a) comparisons for a aliases on this property; no input mutation or effects.
|
|
89
|
+
*/
|
|
90
|
+
function matchesDeclaredKey({ key, name, schema }, _options = {}) {
|
|
91
|
+
if (keyShape(name) === keyShape(key))
|
|
92
|
+
return true;
|
|
93
|
+
if (!object(schema) || !Array.isArray(schema.aliases))
|
|
94
|
+
return false;
|
|
95
|
+
return schema.aliases.some(alias => typeof alias === 'string' && keyShape(alias) === keyShape(key));
|
|
96
|
+
}
|
|
97
|
+
/** Exact names, then case/separator variants and declared aliases, then a sole hint; ambiguous hints stay unresolved
|
|
98
|
+
* for the domain hook or closed-schema refusal. Never choose one of several candidates.
|
|
99
|
+
* Hints apply only where a closed schema would refuse the key: an open schema accepts it as data.
|
|
100
|
+
* @complexity O(p + a) comparisons for p properties and a declared aliases; O(p) space.
|
|
101
|
+
*/
|
|
69
102
|
function canonicalKey({ key, location, shape }) {
|
|
70
|
-
const candidates = Object.hasOwn(shape.properties, key) ? [key] : shape.names.filter(name =>
|
|
103
|
+
const candidates = Object.hasOwn(shape.properties, key) ? [key] : shape.names.filter(name => matchesDeclaredKey({ key, name, schema: shape.properties[name] }));
|
|
71
104
|
if (candidates.length > 1)
|
|
72
105
|
throw new ToolInputError({ message: `Ambiguous input '${location}': matches ${candidates.join(', ')}. Use exactly one canonical property name.` });
|
|
73
|
-
|
|
106
|
+
if (candidates.length === 1 || shape.schema.additionalProperties !== false)
|
|
107
|
+
return candidates[0];
|
|
108
|
+
const hints = shape.names.filter(name => nearMiss({ left: keyShape(key), right: keyShape(name) }));
|
|
109
|
+
return hints.length === 1 ? hints[0] : undefined;
|
|
74
110
|
}
|
|
75
111
|
/** Describe the local schema and likely names without choosing a name or exposing input values.
|
|
76
112
|
* Target and permission fields must be declared in the tool schema before callers can use them.
|
|
@@ -111,7 +147,7 @@ function normalizeObject({ value, schema, step }) {
|
|
|
111
147
|
return Object.fromEntries(Object.entries(value).map(([key, raw]) => normalizeEntry({ key, raw, shape, sources, step })));
|
|
112
148
|
}
|
|
113
149
|
/** Schema-guided copies only: unconstrained data and unsupported schema dialects stay untouched.
|
|
114
|
-
* Cost is O(k*p) per object with k supplied keys and
|
|
150
|
+
* Cost is O(k*(p+a)) comparisons per object with k supplied keys, p properties and a aliases; recursion is bounded
|
|
115
151
|
* to 64 levels so a hostile input/schema cannot exhaust the execution gate's stack.
|
|
116
152
|
*/
|
|
117
153
|
function normalizeValue({ value, schema, step }) {
|
|
@@ -126,14 +162,15 @@ function normalizeValue({ value, schema, step }) {
|
|
|
126
162
|
}
|
|
127
163
|
if (types.includes('array'))
|
|
128
164
|
return normalizeArray({ value, schema, step });
|
|
129
|
-
|
|
165
|
+
const unwrapped = unwrapStringArray({ value, types, step });
|
|
166
|
+
return object(unwrapped) ? normalizeObject({ value: unwrapped, schema, step }) : unwrapped;
|
|
130
167
|
}
|
|
131
168
|
/** A walk from the root of one input. */
|
|
132
169
|
function walkRoot({ input, schema, notes, validateClosed, keysOnly }) {
|
|
133
170
|
return normalizeValue({ value: input, schema, step: { path: '', depth: 0, walk: { notes, validateClosed, keysOnly } } });
|
|
134
171
|
}
|
|
135
|
-
/** Canonicalize one batch layer before merging;
|
|
136
|
-
* final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
172
|
+
/** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
|
|
173
|
+
* and domain hooks for final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
137
174
|
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
138
175
|
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
139
176
|
*/
|
|
@@ -141,7 +178,8 @@ export function canonicalizeToolInputKeys({ input, schema }, _options = {}) {
|
|
|
141
178
|
const notes = [];
|
|
142
179
|
return { input: walkRoot({ input, schema, notes, validateClosed: false, keysOnly: true }), notes };
|
|
143
180
|
}
|
|
144
|
-
/** Normalize before policy, confirmation and handler validation.
|
|
181
|
+
/** Normalize before policy, confirmation and handler validation. Schema properties may declare
|
|
182
|
+
* `aliases: ['search', 'query']`; domain hooks own transformations beyond declared key aliases.
|
|
145
183
|
* Never changes the caller's object or includes values in notes. Invalid/ambiguous input remains
|
|
146
184
|
* a ToolInputError; ordinary domain/schema validation still belongs to the registered handler.
|
|
147
185
|
* @complexity Two bounded schema walks plus the optional domain hook's cost.
|
package/dist/tool-registry.d.ts
CHANGED
|
@@ -46,6 +46,9 @@ export interface ToolDescriptor {
|
|
|
46
46
|
* JSON-Schema-shaped description of this tool's accepted input, carried as `unknown` because the
|
|
47
47
|
* gate reads common property/type fields for input normalization but leaves validation and
|
|
48
48
|
* schema dialects to the handler; no validator dependency belongs in every registry consumer.
|
|
49
|
+
* Each property may declare `aliases: readonly string[]` (e.g. contains: { type: 'string',
|
|
50
|
+
* aliases: ['search', 'query'] }). The gate treats these like case variants, rejects ambiguous
|
|
51
|
+
* or conflicting names, and reports the rename in normalization notes.
|
|
49
52
|
*
|
|
50
53
|
* It exists because *every* discovery mechanism needs it and nothing else in the kernel can
|
|
51
54
|
* supply it: a caller that can enumerate `ToolRegistry.list({})` still cannot tell an agent what
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/core",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "The Jini kernel: typed DI tokens, pack composition and lifecycle, the tool registry, redaction, and origin/API-token auth primitives. Zero dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|