@netnodeag/kraftwerk 0.77.0 → 0.78.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/dist/cli/invite.d.ts +2 -0
- package/dist/cli/invite.js +48 -0
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/client/relay.d.ts +40 -0
- package/dist/client/relay.js +35 -0
- package/dist/client/routes.d.ts +1 -0
- package/dist/client/routes.js +1 -0
- package/dist/core/chat/channel-turns.d.ts +2 -2
- package/dist/core/chat/channel-turns.js +2 -2
- package/dist/core/chat/sessions.d.ts +20 -3
- package/dist/core/chat/sessions.js +22 -7
- package/dist/core/chat/types.d.ts +21 -0
- package/dist/core/cloud.d.ts +38 -1
- package/dist/core/cloud.js +4 -0
- package/dist/core/routines.d.ts +22 -2
- package/dist/core/routines.js +26 -0
- package/dist/server/api/chats.js +45 -8
- package/dist/server/api/cloud.d.ts +16 -2
- package/dist/server/api/cloud.js +15 -2
- package/dist/server/api/devices.d.ts +7 -1
- package/dist/server/api/devices.js +3 -2
- package/dist/server/api/index.d.ts +20 -1
- package/dist/server/api/router.d.ts +3 -2
- package/dist/server/api/router.js +12 -0
- package/dist/server/cloud.d.ts +42 -3
- package/dist/server/cloud.js +77 -2
- package/dist/server/members.d.ts +87 -0
- package/dist/server/members.js +117 -0
- package/dist/server/relay.d.ts +8 -1
- package/dist/server/relay.js +45 -2
- package/dist/server/server.js +7 -0
- package/dist/server/trust.d.ts +16 -0
- package/dist/server/trust.js +12 -1
- package/package.json +1 -1
- package/web/dist/assets/{dist-BGHweZKi.js → dist-B-yVL5lk.js} +1 -1
- package/web/dist/assets/{dist-BVPxOqcx.js → dist-B9EMB1Ie.js} +1 -1
- package/web/dist/assets/{dist-DT2Tjvwx.js → dist-BTc878j7.js} +1 -1
- package/web/dist/assets/{dist-DoD3qX1P.js → dist-C90kUzps.js} +1 -1
- package/web/dist/assets/{dist-DCFREMXk.js → dist-CpLdAqFQ.js} +1 -1
- package/web/dist/assets/{dist-CXoAMU1b.js → dist-Crnb8VVO.js} +1 -1
- package/web/dist/assets/{dist-FFA4-S6d.js → dist-D0rjnN8S.js} +1 -1
- package/web/dist/assets/{dist-yGx0qYbw.js → dist-D4SDXoGw.js} +1 -1
- package/web/dist/assets/{dist-DdA5PoVJ.js → dist-DHRhdODt.js} +1 -1
- package/web/dist/assets/{dist-CCNyRSAA.js → dist-DQbifCX0.js} +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
- package/web/dist/assets/{editor-CyWapLQr.js → editor-DHAdW1g_.js} +3 -3
- package/web/dist/assets/{index-CNXcNoKJ.js → index-D7JVAjll.js} +4 -4
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-zjUUP5a9.js +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { CloudError, memberVerify } from "../core/cloud.js";
|
|
3
|
+
import { machineLink, servedWorkspace } from "./cloud.js";
|
|
4
|
+
/** The header the relay puts on a member session's requests (a client's own is dropped). */
|
|
5
|
+
export const MEMBER_HEADER = "x-kraftwerk-member";
|
|
6
|
+
const DEFAULT_TTL_MS = 5 * 60_000;
|
|
7
|
+
let ttlMs = DEFAULT_TTL_MS;
|
|
8
|
+
/** How long a verified answer counts as fresh — tests shorten it; no argument restores the default. */
|
|
9
|
+
export function setMemberCacheTtl(ms) {
|
|
10
|
+
ttlMs = ms ?? DEFAULT_TTL_MS;
|
|
11
|
+
}
|
|
12
|
+
/** By sha256(token) + workspace: the cache keeps no token in the clear. */
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const inflight = new Map();
|
|
15
|
+
/** Forget every cached answer (tests). */
|
|
16
|
+
export function forgetMemberCache() {
|
|
17
|
+
cache.clear();
|
|
18
|
+
}
|
|
19
|
+
const keyOf = (token, workspaceId) => `${createHash("sha256").update(token).digest("hex")}\0${workspaceId}`;
|
|
20
|
+
const ROLES = new Set(["owner", "member"]);
|
|
21
|
+
function asMember(v) {
|
|
22
|
+
const o = (v ?? {});
|
|
23
|
+
if (typeof o.email !== "string" || typeof o.handle !== "string" || !ROLES.has(String(o.role)))
|
|
24
|
+
return null;
|
|
25
|
+
return { email: o.email, handle: o.handle, role: o.role };
|
|
26
|
+
}
|
|
27
|
+
/** Whether `token` is a member of `workspaceId`: the cache within its TTL, else the cloud. */
|
|
28
|
+
export function verifyMember(token, workspaceId) {
|
|
29
|
+
const key = keyOf(token, workspaceId);
|
|
30
|
+
const hit = cache.get(key);
|
|
31
|
+
if (hit && Date.now() - hit.at < ttlMs)
|
|
32
|
+
return Promise.resolve({ ok: true, member: hit.member });
|
|
33
|
+
const running = inflight.get(key);
|
|
34
|
+
if (running)
|
|
35
|
+
return running;
|
|
36
|
+
const run = (async () => {
|
|
37
|
+
const stale = (why) => {
|
|
38
|
+
const kept = cache.get(key);
|
|
39
|
+
return kept ? { ok: true, member: kept.member, stale: true } : { ok: false, error: why, drop: false };
|
|
40
|
+
};
|
|
41
|
+
const link = machineLink();
|
|
42
|
+
if (!link)
|
|
43
|
+
return stale("this machine is not connected to the kraftwerk cloud — members cannot be verified");
|
|
44
|
+
try {
|
|
45
|
+
const member = asMember(await memberVerify(link.url, link.creds, token, workspaceId, link.version));
|
|
46
|
+
if (!member)
|
|
47
|
+
return stale("the cloud's answer about the member was not understood");
|
|
48
|
+
cache.set(key, { member, at: Date.now() });
|
|
49
|
+
return { ok: true, member };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const status = err instanceof CloudError ? err.status : 0;
|
|
53
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
54
|
+
cache.delete(key);
|
|
55
|
+
const why = status === 403 ? "this machine does not host that workspace" : "unknown or revoked member token";
|
|
56
|
+
return { ok: false, error: `${why}${err.message ? ` (${err.message})` : ""}`, drop: true };
|
|
57
|
+
}
|
|
58
|
+
return stale(`could not reach the kraftwerk cloud to verify the member: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
inflight.set(key, run);
|
|
62
|
+
void run.finally(() => inflight.delete(key));
|
|
63
|
+
return run;
|
|
64
|
+
}
|
|
65
|
+
const sessions = new Map();
|
|
66
|
+
const WS_ID = /^ws_[A-Za-z0-9_-]{1,64}$/;
|
|
67
|
+
/**
|
|
68
|
+
* A member's hello: verify the token for the workspace, find the workspace
|
|
69
|
+
* this daemon serves under that id, and open a session. Its id is what the
|
|
70
|
+
* relay tags the session's requests with.
|
|
71
|
+
*/
|
|
72
|
+
export async function openMemberSession(token, workspaceId) {
|
|
73
|
+
if (typeof token !== "string" || !token.startsWith("kwm_") || token.length > 512)
|
|
74
|
+
return { ok: false, error: "memberToken must be a kwm_… member token" };
|
|
75
|
+
if (typeof workspaceId !== "string" || !WS_ID.test(workspaceId))
|
|
76
|
+
return { ok: false, error: "workspaceId must be a ws_… workspace id" };
|
|
77
|
+
const v = await verifyMember(token, workspaceId);
|
|
78
|
+
if (!v.ok)
|
|
79
|
+
return { ok: false, error: v.error };
|
|
80
|
+
const ws = await servedWorkspace(workspaceId);
|
|
81
|
+
if (!ws)
|
|
82
|
+
return { ok: false, error: "this machine does not serve that workspace right now" };
|
|
83
|
+
const id = randomBytes(24).toString("base64url");
|
|
84
|
+
sessions.set(id, { token, workspaceId, slug: ws.slug, name: ws.name, member: v.member });
|
|
85
|
+
return { ok: true, id, member: v.member, workspace: { id: workspaceId, slug: ws.slug, name: ws.name } };
|
|
86
|
+
}
|
|
87
|
+
/** The session behind a request's tag, or undefined. */
|
|
88
|
+
export function memberSession(id) {
|
|
89
|
+
const s = sessions.get(id);
|
|
90
|
+
return s ? { member: s.member, workspaceId: s.workspaceId, slug: s.slug } : undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Before each of a session's requests: still a member? Within the TTL this
|
|
94
|
+
* is the cache; past it, the cloud (a cloud that cannot be reached keeps the
|
|
95
|
+
* cached answer). A refusal ends the session.
|
|
96
|
+
*/
|
|
97
|
+
export async function checkMemberSession(id) {
|
|
98
|
+
const s = sessions.get(id);
|
|
99
|
+
if (!s)
|
|
100
|
+
return { ok: false, error: "the member session ended", ended: true };
|
|
101
|
+
const v = await verifyMember(s.token, s.workspaceId);
|
|
102
|
+
if (!v.ok && v.drop) {
|
|
103
|
+
sessions.delete(id);
|
|
104
|
+
return { ok: false, error: v.error, ended: true };
|
|
105
|
+
}
|
|
106
|
+
if (!v.ok)
|
|
107
|
+
return { ok: false, error: v.error, ended: false };
|
|
108
|
+
s.member = v.member;
|
|
109
|
+
// The workspace may have been reopened under another slug meanwhile.
|
|
110
|
+
const ws = await servedWorkspace(s.workspaceId);
|
|
111
|
+
if (ws)
|
|
112
|
+
s.slug = ws.slug;
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
export function closeMemberSession(id) {
|
|
116
|
+
sessions.delete(id);
|
|
117
|
+
}
|
package/dist/server/relay.d.ts
CHANGED
|
@@ -5,7 +5,14 @@ import type { PushMessage } from "./push.js";
|
|
|
5
5
|
* it, and every request that comes through answered by this daemon's own
|
|
6
6
|
* server — as a request from a network device, never as this machine. So a
|
|
7
7
|
* client needs a paired device's token (or pairs with a code) exactly as on
|
|
8
|
-
* the LAN.
|
|
8
|
+
* the LAN. Every request is marked `x-kraftwerk-relay: 1`, so a paired
|
|
9
|
+
* device over the relay counts as a remote caller (trust.ts).
|
|
10
|
+
*
|
|
11
|
+
* Or a channel is a remote member's: its first sealed message is
|
|
12
|
+
* {t:"auth", memberToken, workspaceId} (see members.ts for the answer and
|
|
13
|
+
* the cache). From then on every request of the channel carries the
|
|
14
|
+
* session's tag (members.ts MEMBER_HEADER) and is re-checked against the
|
|
15
|
+
* cache first; a membership the cloud revoked closes the channel.
|
|
9
16
|
*
|
|
10
17
|
* Machine settings live in ~/.kraftwerk/relay.json (owner-only): whether
|
|
11
18
|
* the relay is on, which relay, the machine's key pair, and the secret the
|
package/dist/server/relay.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, fromBase64Url, } from "../client/relay.js";
|
|
7
7
|
import { CLOUD_DEFAULT_URL } from "../config.js";
|
|
8
8
|
import { dropPushToken } from "../core/devices.js";
|
|
9
|
+
import { checkMemberSession, closeMemberSession, MEMBER_HEADER, openMemberSession } from "./members.js";
|
|
9
10
|
const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
|
|
10
11
|
export async function readRelaySettings() {
|
|
11
12
|
try {
|
|
@@ -114,6 +115,8 @@ export async function startRelay(opts) {
|
|
|
114
115
|
return;
|
|
115
116
|
for (const r of conn.inflight.values())
|
|
116
117
|
r.destroy();
|
|
118
|
+
if (conn.member)
|
|
119
|
+
closeMemberSession(conn.member);
|
|
117
120
|
conns.delete(c);
|
|
118
121
|
status.clients = conns.size;
|
|
119
122
|
};
|
|
@@ -125,9 +128,12 @@ export async function startRelay(opts) {
|
|
|
125
128
|
/** One tunnelled request, answered by this daemon's own server as a network device's request. */
|
|
126
129
|
const forward = (c, conn, req) => {
|
|
127
130
|
const headers = {};
|
|
131
|
+
// x-kraftwerk-* are the daemon's own marks (relay, member session): never the client's to set.
|
|
128
132
|
for (const [k, v] of Object.entries(req.headers ?? {}))
|
|
129
|
-
if (!DROP_REQUEST.has(k.toLowerCase()))
|
|
133
|
+
if (!DROP_REQUEST.has(k.toLowerCase()) && !k.toLowerCase().startsWith("x-kraftwerk-"))
|
|
130
134
|
headers[k.toLowerCase()] = String(v);
|
|
135
|
+
if (conn.member)
|
|
136
|
+
headers[MEMBER_HEADER] = conn.member;
|
|
131
137
|
const clientHost = typeof req.headers?.host === "string" && /^[A-Za-z0-9.:[\]-]+$/.test(req.headers.host) ? req.headers.host : "relay.invalid";
|
|
132
138
|
// Marked as forwarded: trust.ts never takes a forwarded request for this machine, whatever its peer address.
|
|
133
139
|
Object.assign(headers, { host: `127.0.0.1:${opts.port}`, "x-forwarded-for": "relay", "x-forwarded-host": clientHost, "x-forwarded-proto": "https", "x-kraftwerk-relay": "1" });
|
|
@@ -189,8 +195,45 @@ export async function startRelay(opts) {
|
|
|
189
195
|
dropConn(c);
|
|
190
196
|
return;
|
|
191
197
|
}
|
|
192
|
-
if (msg.t === "
|
|
198
|
+
if (msg.t === "auth") {
|
|
199
|
+
if (conn.member || conn.used) {
|
|
200
|
+
await reply(c, conn, { t: "auth", ok: false, error: "authenticate once, before the first request" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const r = await openMemberSession(msg.memberToken, msg.workspaceId).catch((err) => ({ ok: false, error: err.message }));
|
|
204
|
+
if (!conns.has(c)) {
|
|
205
|
+
if (r.ok)
|
|
206
|
+
closeMemberSession(r.id);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!r.ok) {
|
|
210
|
+
log(`relay: member refused — ${r.error}`);
|
|
211
|
+
await reply(c, conn, { t: "auth", ok: false, error: r.error });
|
|
212
|
+
send({ t: "close", c, reason: r.error.slice(0, 100) });
|
|
213
|
+
dropConn(c);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
conn.member = r.id;
|
|
217
|
+
await reply(c, conn, { t: "auth", ok: true, member: r.member, workspace: r.workspace });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (msg.t === "req" && typeof msg.id === "number") {
|
|
221
|
+
conn.used = true;
|
|
222
|
+
if (conn.member) {
|
|
223
|
+
// Past the cache's TTL this asks the cloud again; a revoked membership ends the channel.
|
|
224
|
+
const check = await checkMemberSession(conn.member);
|
|
225
|
+
if (!check.ok) {
|
|
226
|
+
await reply(c, conn, { t: "fail", id: msg.id, error: check.error });
|
|
227
|
+
if (check.ended) {
|
|
228
|
+
log(`relay: member session dropped — ${check.error}`);
|
|
229
|
+
send({ t: "close", c, reason: check.error.slice(0, 100) });
|
|
230
|
+
dropConn(c);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
193
235
|
forward(c, conn, msg);
|
|
236
|
+
}
|
|
194
237
|
else if (msg.t === "abort") {
|
|
195
238
|
const r = conn.inflight.get(msg.id);
|
|
196
239
|
conn.inflight.delete(msg.id);
|
package/dist/server/server.js
CHANGED
|
@@ -258,6 +258,10 @@ export async function startHub(opts) {
|
|
|
258
258
|
return json(res, { error: `no workspace "${target.slug}" here` }, 404);
|
|
259
259
|
const { url } = target;
|
|
260
260
|
const trust = await trustOf(req, url, trustOpts);
|
|
261
|
+
// A member session (members.ts) reaches its one workspace and nothing else: not another, not the hub page.
|
|
262
|
+
if (trust.kind === "member" && open?.slug !== trust.slug) {
|
|
263
|
+
return json(res, { error: `a member session reaches only its workspace — /w/${trust.slug}/…` }, 403);
|
|
264
|
+
}
|
|
261
265
|
const run = (fn) => runInHub(hub, () => (open ? open.ws.run(fn) : fn()));
|
|
262
266
|
if (url.pathname.startsWith("/api/")) {
|
|
263
267
|
const method = req.method ?? "GET";
|
|
@@ -298,6 +302,9 @@ export async function startHub(opts) {
|
|
|
298
302
|
const trust = await trustOf(req, url, trustOpts, true);
|
|
299
303
|
if (trust.kind === "none")
|
|
300
304
|
return void socket.end("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n");
|
|
305
|
+
// A socket may name any workspace per message; a member session's scope is per request, so it gets none.
|
|
306
|
+
if (trust.kind === "member")
|
|
307
|
+
return void socket.end("HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n");
|
|
301
308
|
if (url.pathname !== "/api/ws") {
|
|
302
309
|
if (!open)
|
|
303
310
|
return void socket.destroy();
|
package/dist/server/trust.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type http from "node:http";
|
|
2
2
|
import { type Device } from "../core/devices.js";
|
|
3
|
+
import { type Member } from "./members.js";
|
|
3
4
|
/**
|
|
4
5
|
* Who is asking. Every request and socket gets one of:
|
|
5
6
|
*
|
|
@@ -17,6 +18,13 @@ import { type Device } from "../core/devices.js";
|
|
|
17
18
|
* - `device`: a paired device's token — `Authorization: Bearer` (apps),
|
|
18
19
|
* the `kw_device` cookie (a phone's browser, set on pairing), or `?token=`
|
|
19
20
|
* on the socket for runtimes whose WebSocket cannot send headers.
|
|
21
|
+
* `relay`: the request came through the relay (server/relay.ts marks
|
|
22
|
+
* it) — a remote caller, who does not see private routines' chats.
|
|
23
|
+
* - `member`: a remote member of ONE workspace, over the relay: the relay
|
|
24
|
+
* verified their member token with the cloud (server/members.ts) and
|
|
25
|
+
* tags each request with the session's random id, which only this
|
|
26
|
+
* process knows. Scoped to that workspace: server.ts refuses every other
|
|
27
|
+
* address, router.ts every machine-level route.
|
|
20
28
|
* - `none`: anyone else. Gets the web UI's shell (to show the pairing
|
|
21
29
|
* screen), the protocol description and the pairing route — nothing else.
|
|
22
30
|
*/
|
|
@@ -25,9 +33,17 @@ export type Trust = {
|
|
|
25
33
|
} | {
|
|
26
34
|
kind: "device";
|
|
27
35
|
device: Device;
|
|
36
|
+
relay?: true;
|
|
37
|
+
} | {
|
|
38
|
+
kind: "member";
|
|
39
|
+
member: Member;
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
slug: string;
|
|
28
42
|
} | {
|
|
29
43
|
kind: "none";
|
|
30
44
|
};
|
|
45
|
+
/** A caller from afar: a member session, or a paired device over the relay. Private routines' chats are not theirs to see. */
|
|
46
|
+
export declare const remoteCaller: (t: Trust) => boolean;
|
|
31
47
|
export declare const DEVICE_COOKIE = "kw_device";
|
|
32
48
|
/** The device token a request carries, if any. `?token=` only counts where asked (the socket). */
|
|
33
49
|
export declare function tokenOf(req: http.IncomingMessage, url: URL, allowQuery: boolean): string | undefined;
|
package/dist/server/trust.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { deviceForToken } from "../core/devices.js";
|
|
2
|
+
import { MEMBER_HEADER, memberSession } from "./members.js";
|
|
3
|
+
/** A caller from afar: a member session, or a paired device over the relay. Private routines' chats are not theirs to see. */
|
|
4
|
+
export const remoteCaller = (t) => t.kind === "member" || (t.kind === "device" && t.relay === true);
|
|
2
5
|
export const DEVICE_COOKIE = "kw_device";
|
|
3
6
|
const LOOPBACK_PEER = /^(127\.|::1$|::ffff:127\.)/;
|
|
4
7
|
const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
@@ -34,9 +37,17 @@ export function tokenOf(req, url, allowQuery) {
|
|
|
34
37
|
/** The request's trust: this machine (or a trusted network), else a paired device's token, else none. */
|
|
35
38
|
export async function trustOf(req, url, opts, allowQueryToken = false) {
|
|
36
39
|
const peer = req.socket.remoteAddress ?? "";
|
|
40
|
+
// A member session's tag wins over anything else the request says: its scope must hold whatever token rides along.
|
|
41
|
+
const tag = req.headers[MEMBER_HEADER];
|
|
42
|
+
if (typeof tag === "string" && tag) {
|
|
43
|
+
const s = memberSession(tag);
|
|
44
|
+
return s ? { kind: "member", member: s.member, workspaceId: s.workspaceId, slug: s.slug } : { kind: "none" };
|
|
45
|
+
}
|
|
37
46
|
if ((opts.trustLoopback && LOOPBACK_PEER.test(peer) && addressedLocally(req)) || (opts.trustNetwork && !LOOPBACK_PEER.test(peer)))
|
|
38
47
|
return { kind: "local" };
|
|
39
48
|
const token = tokenOf(req, url, allowQueryToken);
|
|
40
49
|
const device = token ? await deviceForToken(token) : null;
|
|
41
|
-
|
|
50
|
+
if (!device)
|
|
51
|
+
return { kind: "none" };
|
|
52
|
+
return req.headers["x-kraftwerk-relay"] === "1" ? { kind: "device", device, relay: true } : { kind: "device", device };
|
|
42
53
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netnodeag/kraftwerk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|