@aliyunrds/ctxdb 1.0.8-beta.3 → 1.0.8-beta.4
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 +39 -6
- package/dist/{chunk-EI63DQX3.js → chunk-5DGRNP55.js} +1 -1
- package/dist/{chunk-R67JELM7.js → chunk-5ITT5GSP.js} +3 -2
- package/dist/{chunk-LZ2LOWZL.js → chunk-6MEWI73Y.js} +2 -2
- package/dist/{chunk-ZFS6OMWE.js → chunk-76AXXLVE.js} +2 -2
- package/dist/{chunk-AUBVVYQL.js → chunk-O6MKQ2I3.js} +1 -1
- package/dist/{chunk-ULAWTBBC.js → chunk-OEZM2ZYA.js} +1 -1
- package/dist/{chunk-VIG4SYLU.js → chunk-PI2SUS3M.js} +1 -1
- package/dist/{chunk-BUK4SZC2.js → chunk-SVO6H62S.js} +36 -17
- package/dist/{chunk-7OX6UVPB.js → chunk-WJTV7JNR.js} +3 -3
- package/dist/{chunk-3NJ37TEY.js → chunk-ZW7YXNJ6.js} +1 -1
- package/dist/cli/main.js +282 -72
- package/dist/hooks/hermes-post-llm-call.js +5 -5
- package/dist/hooks/hermes-pre-llm-call.js +8 -8
- package/dist/hooks/pre-tool-use.js +1 -1
- package/dist/hooks/session-start.js +7 -7
- package/dist/hooks/stop.js +5 -5
- package/dist/hooks/user-prompt-submit.js +7 -7
- package/dist/opencode/index.js +611 -36
- package/dist/workers/version-check.js +3 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,9 +17,42 @@ Config and logs live under `~/.ctxdb/`. `~/.ctxdb/ctxdb.json` is one file, but r
|
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
|
+
### Public Device Flow login (requires the enabled Core/API phase 2 service)
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
ctxdb login --agent qoder --server https://<core-host> --no-browser
|
|
24
|
+
# Complete the displayed user code through the console/independent POP test client.
|
|
25
|
+
ctxdb auth status --agent qoder --json
|
|
26
|
+
ctxdb setup --agent qoder
|
|
27
|
+
ctxdb logout --agent qoder --json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Public login uses Core Device Flow only; it never calls POP, starts a localhost callback,
|
|
31
|
+
or uses the internal login server. `--device-code` explicitly selects the same default flow.
|
|
32
|
+
Device Flow always uses `default`, following the current Member permissions and resource ACLs.
|
|
33
|
+
`login` does not accept `--scope`; creation and approval do not send a scope selector.
|
|
34
|
+
Login does not install hooks/skills. Run setup separately for each selected Agent.
|
|
35
|
+
|
|
36
|
+
Each profile contains only an `oauth_credential_ref`; AT/RT live in owner-only files under
|
|
37
|
+
`~/.ctxdb/oauth-credentials/`. `CTXDB_CONFIG_PATH` can select an isolated configuration file;
|
|
38
|
+
the OAuth directory is its sibling. CLI, Hooks and OpenCode share a cross-process, single-flight
|
|
39
|
+
refresh implementation. A lost refresh response or crashed refresh requires a new login, not
|
|
40
|
+
a retry of the old RT. Managed failure/logout never falls back to an API key or another profile.
|
|
41
|
+
Re-login replaces only the selected profile; logout reports remote-revoke failure separately.
|
|
42
|
+
AT/RT must not be copied into Agent/Skill configuration or handed to a sandbox.
|
|
43
|
+
|
|
44
|
+
The optional sibling `device-environments.json` (`version: 1`, `environments: [{core_url,
|
|
45
|
+
authorization_url}]`) maps a Core origin to a deployment-approved HTTPS console page.
|
|
46
|
+
CLI appends only `user_code` to that locally configured URL. There is no server discovery or
|
|
47
|
+
invented default console page. Without a mapping, the code can be used with the independent POP
|
|
48
|
+
test client. This phase never opens a browser automatically; `--no-browser` is supported.
|
|
49
|
+
|
|
50
|
+
POSIX uses private files/directories; Windows validates an owner-only ACL using local PowerShell.
|
|
51
|
+
Native Windows acceptance remains separate from the macOS local test record.
|
|
52
|
+
|
|
20
53
|
### Internal browser-login distribution
|
|
21
54
|
|
|
22
|
-
The same source also builds `@ali/ctxdb-internal`.
|
|
55
|
+
The same source also builds `@ali/ctxdb-internal`. Its existing prototype
|
|
23
56
|
adds `ctxdb login --server <url>`, `ctxdb login --device-code`, `ctxdb logout`,
|
|
24
57
|
and `ctxdb auth status`. The default login uses a browser loopback callback;
|
|
25
58
|
`--device-code` supports remote or headless terminals. Both methods discover a
|
|
@@ -36,14 +69,14 @@ credential overrides the API key and DATA URL in the existing v2
|
|
|
36
69
|
three while `ctxdb logout` rejects the two credential overrides, so command
|
|
37
70
|
output cannot disagree with subsequent DATA requests. Re-login replaces the
|
|
38
71
|
active connection and revokes the previous key after setup succeeds. The
|
|
39
|
-
public `@aliyunrds/ctxdb` build
|
|
40
|
-
continues to
|
|
72
|
+
public `@aliyunrds/ctxdb` build uses the distinct Device Flow implementation above and
|
|
73
|
+
continues to support API-key setup. It never reads the internal credential store. Build the
|
|
41
74
|
internal staging package with `pnpm build:internal`.
|
|
42
75
|
|
|
43
76
|
The distinction is a compile-time distribution manifest, not a runtime
|
|
44
77
|
environment-variable or file-existence check. The `public` manifest disables
|
|
45
|
-
`interactiveLogin` and `managedCredentials
|
|
46
|
-
|
|
78
|
+
internal-protocol flags `interactiveLogin` and `managedCredentials`, and enables `deviceFlow`;
|
|
79
|
+
the `internal` manifest enables the former two and disables `deviceFlow`. The CLI hooks and the separately bundled OpenCode plugin receive the
|
|
47
80
|
same manifest, so installing a public build on a machine that happens to have
|
|
48
81
|
`~/.ctxdb/credentials.json` does not make that build consume the file.
|
|
49
82
|
|
|
@@ -607,7 +640,7 @@ Field reference:
|
|
|
607
640
|
|
|
608
641
|
Env-var overrides apply to the selected agent config (env wins): `CTXDB_AGENT` / `CTXDB_ACCESS_TOKEN` / `CTXDB_API_KEY` / `CTXDB_BASE_URL` / `CTXDB_USER_ID` / `CTXDB_AGENT_ID` / `CTXDB_APP_ID`. The internal login command additionally reads `CTXDB_LOGIN_SERVER` as its provider-neutral login endpoint. The last two default to unset — the request body omits `agent_id`/`app_id` entirely; set them only when you want per-agent / per-app scope isolation on the server.
|
|
609
642
|
|
|
610
|
-
Downstream Agent hosts can set `CTXDB_ACCESS_TOKEN` to send `Authorization: Bearer <token>` to the Data API. It is runtime-only, never written to `~/.ctxdb/ctxdb.json`, and takes precedence over
|
|
643
|
+
Downstream Agent hosts can set `CTXDB_ACCESS_TOKEN` to send `Authorization: Bearer <token>` to the Data API. It is runtime-only, never written to `~/.ctxdb/ctxdb.json`, and takes precedence over a managed session or API key for CLI, process hooks, and OpenCode; a rejected token does not fall back to another credential. `ctxdb setup` can use a managed reference or an environment AT for validation, but never persists the AT. An environment-only credential must remain available to the Agent runtime after setup.
|
|
611
644
|
|
|
612
645
|
Hook/plugin escape hatch: set `CTXDB_SKIP_HOOKS=TRUE` on the agent process to make all ctxdb hook entrypoints exit immediately before reading config or calling the API. Direct `ctxdb memory` / `ctxdb kb` / `ctxdb setup` CLI commands are unchanged.
|
|
613
646
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
define_CTXDB_DISTRIBUTION_MANIFEST_default
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-PI2SUS3M.js";
|
|
5
5
|
|
|
6
6
|
// src/lib/distribution-capabilities.ts
|
|
7
7
|
var PUBLIC_DISTRIBUTION = {
|
|
@@ -10,7 +10,8 @@ var PUBLIC_DISTRIBUTION = {
|
|
|
10
10
|
packageRegistry: null,
|
|
11
11
|
capabilities: {
|
|
12
12
|
interactiveLogin: false,
|
|
13
|
-
managedCredentials: false
|
|
13
|
+
managedCredentials: false,
|
|
14
|
+
deviceFlow: true
|
|
14
15
|
}
|
|
15
16
|
};
|
|
16
17
|
var DISTRIBUTION_MANIFEST = typeof define_CTXDB_DISTRIBUTION_MANIFEST_default === "undefined" ? PUBLIC_DISTRIBUTION : define_CTXDB_DISTRIBUTION_MANIFEST_default;
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import {
|
|
3
3
|
fetchKbCatalogBlock,
|
|
4
4
|
recallTurn
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-WJTV7JNR.js";
|
|
6
6
|
import {
|
|
7
7
|
debug
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-SVO6H62S.js";
|
|
9
9
|
|
|
10
10
|
// src/lib/warmup-recall.ts
|
|
11
11
|
import { execSync } from "child_process";
|
|
@@ -4,13 +4,13 @@ import {
|
|
|
4
4
|
isConnectionError,
|
|
5
5
|
resetCircuit,
|
|
6
6
|
tripCircuit
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-5DGRNP55.js";
|
|
8
8
|
import {
|
|
9
9
|
CtxdbError,
|
|
10
10
|
debug,
|
|
11
11
|
isDataApiReady,
|
|
12
12
|
isDebug
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-SVO6H62S.js";
|
|
14
14
|
|
|
15
15
|
// src/lib/capture-orchestrator.ts
|
|
16
16
|
import {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// <define:__CTXDB_DISTRIBUTION_MANIFEST__>
|
|
4
|
-
var define_CTXDB_DISTRIBUTION_MANIFEST_default = { id: "public", packageName: "@aliyunrds/ctxdb", packageRegistry: null, capabilities: { interactiveLogin: false, managedCredentials: false } };
|
|
4
|
+
var define_CTXDB_DISTRIBUTION_MANIFEST_default = { id: "public", packageName: "@aliyunrds/ctxdb", packageRegistry: null, capabilities: { interactiveLogin: false, managedCredentials: false, deviceFlow: true } };
|
|
5
5
|
|
|
6
6
|
export {
|
|
7
7
|
define_CTXDB_DISTRIBUTION_MANIFEST_default
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
DISTRIBUTION_MANIFEST
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-5ITT5GSP.js";
|
|
5
5
|
|
|
6
6
|
// src/lib/credentials.ts
|
|
7
7
|
function apiKeyCredential(apiKey) {
|
|
@@ -10,6 +10,8 @@ function apiKeyCredential(apiKey) {
|
|
|
10
10
|
function resolveDataApiCredential(cfg, env = process.env) {
|
|
11
11
|
const accessToken = env.CTXDB_ACCESS_TOKEN?.trim();
|
|
12
12
|
if (accessToken) return { type: "access-token", value: accessToken };
|
|
13
|
+
if (env.CTXDB_API_KEY) return apiKeyCredential(env.CTXDB_API_KEY);
|
|
14
|
+
if (cfg.oauthCredential) return { type: "oauth-session", profile: cfg.oauthCredential };
|
|
13
15
|
return cfg.apiKey ? apiKeyCredential(cfg.apiKey) : null;
|
|
14
16
|
}
|
|
15
17
|
function isDataApiReady(cfg, env = process.env) {
|
|
@@ -82,6 +84,7 @@ var PACKAGE_NAME = PACKAGE_IDENTITY.name;
|
|
|
82
84
|
var PACKAGE_VERSION = PACKAGE_IDENTITY.version;
|
|
83
85
|
|
|
84
86
|
// src/lib/http-client.ts
|
|
87
|
+
import { OAuthProfileProvider } from "@aliyunrds/ctxdb-shared";
|
|
85
88
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
86
89
|
var CtxdbError = class extends Error {
|
|
87
90
|
constructor(message) {
|
|
@@ -175,7 +178,7 @@ var HttpClient = class {
|
|
|
175
178
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
176
179
|
this.credential = credential;
|
|
177
180
|
this.apiKey = opts.apiKey ?? "";
|
|
178
|
-
this.authorizationProvider = opts.authorizationProvider;
|
|
181
|
+
this.authorizationProvider = opts.authorizationProvider ?? (credential?.type === "oauth-session" ? new OAuthProfileProvider(credential.profile) : void 0);
|
|
179
182
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
180
183
|
this.userAgent = opts.userAgent ?? `ctxdb-cli/${PACKAGE_VERSION}`;
|
|
181
184
|
this.extraHeaders = { ...opts.extraHeaders ?? {} };
|
|
@@ -190,6 +193,10 @@ var HttpClient = class {
|
|
|
190
193
|
...this.extraHeaders,
|
|
191
194
|
...requestHeaders
|
|
192
195
|
};
|
|
196
|
+
if (authorization.scheme === "Bearer") {
|
|
197
|
+
for (const key of Object.keys(h)) if (key.toLowerCase() === "authorization") delete h[key];
|
|
198
|
+
h.Authorization = `Bearer ${authorization.value}`;
|
|
199
|
+
}
|
|
193
200
|
if (contentType) h["Content-Type"] = contentType;
|
|
194
201
|
return h;
|
|
195
202
|
}
|
|
@@ -199,7 +206,7 @@ var HttpClient = class {
|
|
|
199
206
|
authorization = await this.authorizationProvider.resolve();
|
|
200
207
|
} else {
|
|
201
208
|
const credential = this.credential;
|
|
202
|
-
if (!credential) throw new CtxdbError("DATA API credential is required");
|
|
209
|
+
if (!credential || credential.type === "oauth-session") throw new CtxdbError("DATA API credential is required");
|
|
203
210
|
authorization = {
|
|
204
211
|
scheme: credential.type === "access-token" ? "Bearer" : "Token",
|
|
205
212
|
value: credential.value,
|
|
@@ -207,6 +214,10 @@ var HttpClient = class {
|
|
|
207
214
|
expiresAt: null
|
|
208
215
|
};
|
|
209
216
|
}
|
|
217
|
+
const safeText = (value) => {
|
|
218
|
+
const text = String(value);
|
|
219
|
+
return authorization.scheme === "Bearer" ? text.split(authorization.value).join("[REDACTED]") : text;
|
|
220
|
+
};
|
|
210
221
|
let url = `${authorization.baseUrl.replace(/\/+$/, "")}${path}`;
|
|
211
222
|
if (init.params) {
|
|
212
223
|
const qs = new URLSearchParams();
|
|
@@ -236,15 +247,16 @@ var HttpClient = class {
|
|
|
236
247
|
init.headers
|
|
237
248
|
),
|
|
238
249
|
body: init.body,
|
|
239
|
-
signal: controller.signal
|
|
250
|
+
signal: controller.signal,
|
|
251
|
+
...authorization.scheme === "Bearer" ? { redirect: "error" } : {}
|
|
240
252
|
});
|
|
241
253
|
} catch (err) {
|
|
242
254
|
if (err?.name === "AbortError") {
|
|
243
255
|
if (dbg) debug("http", `\u2717 ${method} ${path} timeout after ${Date.now() - t0}ms`);
|
|
244
256
|
throw new CtxdbError(`request timeout after ${effectiveTimeout}ms: ${url}`);
|
|
245
257
|
}
|
|
246
|
-
if (dbg) debug("http", `\u2717 ${method} ${path} network error after ${Date.now() - t0}ms: ${err?.message ?? err}`);
|
|
247
|
-
throw new CtxdbError(`network error contacting ${url}: ${err?.message ?? err}`);
|
|
258
|
+
if (dbg) debug("http", `\u2717 ${method} ${path} network error after ${Date.now() - t0}ms: ${safeText(err?.message ?? err)}`);
|
|
259
|
+
throw new CtxdbError(`network error contacting ${url}: ${safeText(err?.message ?? err)}`);
|
|
248
260
|
}
|
|
249
261
|
if (resp.status === 204) {
|
|
250
262
|
if (dbg) debug("http", `\u2190 ${method} ${path} 204 (${Date.now() - t0}ms)`);
|
|
@@ -252,14 +264,14 @@ var HttpClient = class {
|
|
|
252
264
|
}
|
|
253
265
|
let text;
|
|
254
266
|
try {
|
|
255
|
-
text = await resp.text();
|
|
267
|
+
text = safeText(await resp.text());
|
|
256
268
|
} catch (err) {
|
|
257
269
|
if (err?.name === "AbortError") {
|
|
258
270
|
if (dbg) debug("http", `\u2717 ${method} ${path} body-read timeout after ${Date.now() - t0}ms`);
|
|
259
271
|
throw new CtxdbError(`request timeout after ${effectiveTimeout}ms (body read): ${url}`);
|
|
260
272
|
}
|
|
261
|
-
if (dbg) debug("http", `\u2717 ${method} ${path} body-read error after ${Date.now() - t0}ms: ${err?.message ?? err}`);
|
|
262
|
-
throw new CtxdbError(`network error reading body from ${url}: ${err?.message ?? err}`);
|
|
273
|
+
if (dbg) debug("http", `\u2717 ${method} ${path} body-read error after ${Date.now() - t0}ms: ${safeText(err?.message ?? err)}`);
|
|
274
|
+
throw new CtxdbError(`network error reading body from ${url}: ${safeText(err?.message ?? err)}`);
|
|
263
275
|
}
|
|
264
276
|
if (!resp.ok) {
|
|
265
277
|
const parsedError = parseErrorResponse(text, `HTTP ${resp.status}`);
|
|
@@ -824,10 +836,11 @@ function readActiveCredentialSync(path = defaultCredentialsPath()) {
|
|
|
824
836
|
|
|
825
837
|
// src/lib/config.ts
|
|
826
838
|
import {
|
|
827
|
-
resolveDebugPolicy
|
|
839
|
+
resolveDebugPolicy,
|
|
840
|
+
oauthProfile
|
|
828
841
|
} from "@aliyunrds/ctxdb-shared";
|
|
829
|
-
function defaultConfigPath() {
|
|
830
|
-
return join5(homedir4(), ".ctxdb", "ctxdb.json");
|
|
842
|
+
function defaultConfigPath(env = process.env) {
|
|
843
|
+
return env.CTXDB_CONFIG_PATH || join5(homedir4(), ".ctxdb", "ctxdb.json");
|
|
831
844
|
}
|
|
832
845
|
var DEFAULT_CONFIG_PATH = join5(homedir4(), ".ctxdb", "ctxdb.json");
|
|
833
846
|
function configDir() {
|
|
@@ -928,8 +941,8 @@ function applyEnv(cfg, env) {
|
|
|
928
941
|
return applyDebugPolicy(cfg);
|
|
929
942
|
}
|
|
930
943
|
function load(options = {}) {
|
|
931
|
-
const path = options.path ?? defaultConfigPath();
|
|
932
944
|
const env = options.env ?? process.env;
|
|
945
|
+
const path = options.path ?? defaultConfigPath(env);
|
|
933
946
|
const raw = readRaw(path);
|
|
934
947
|
const agent = resolveConfigAgent({ ...options, path, env }, raw);
|
|
935
948
|
if (!isV2Schema(raw)) {
|
|
@@ -955,7 +968,7 @@ function load(options = {}) {
|
|
|
955
968
|
const defaultRaw = agentRawFromFile(raw, "default");
|
|
956
969
|
if (Object.keys(defaultRaw).length > 0) {
|
|
957
970
|
for (const [k, v] of Object.entries(defaultRaw)) {
|
|
958
|
-
if (!(k in agentRaw)) {
|
|
971
|
+
if (k !== "oauth_credential_ref" && !(k in agentRaw)) {
|
|
959
972
|
agentRaw[k] = v;
|
|
960
973
|
}
|
|
961
974
|
}
|
|
@@ -969,11 +982,16 @@ function load(options = {}) {
|
|
|
969
982
|
cfg.apiKey = managed.payload.apiKey;
|
|
970
983
|
cfg.baseUrl = managed.payload.baseUrl;
|
|
971
984
|
}
|
|
972
|
-
|
|
985
|
+
applyEnv(cfg, env);
|
|
986
|
+
if (DISTRIBUTION_MANIFEST.capabilities.deviceFlow) {
|
|
987
|
+
cfg.oauthCredential = oauthProfile(agentRaw.oauth_credential_ref, path, agent, cfg.baseUrl);
|
|
988
|
+
}
|
|
989
|
+
return cfg;
|
|
973
990
|
}
|
|
974
991
|
function configToDisk(cfg) {
|
|
975
992
|
return {
|
|
976
|
-
|
|
993
|
+
...cfg.oauthCredential ? { oauth_credential_ref: cfg.oauthCredential.reference } : {},
|
|
994
|
+
api_key: cfg.oauthCredential ? null : cfg.apiKey,
|
|
977
995
|
base_url: cfg.baseUrl,
|
|
978
996
|
user_id: cfg.userId,
|
|
979
997
|
agent_id: cfg.agentId,
|
|
@@ -1081,7 +1099,6 @@ function writeInstalledPkgVersion(version, path) {
|
|
|
1081
1099
|
}
|
|
1082
1100
|
|
|
1083
1101
|
export {
|
|
1084
|
-
apiKeyCredential,
|
|
1085
1102
|
resolveDataApiCredential,
|
|
1086
1103
|
isDataApiReady,
|
|
1087
1104
|
setDebug,
|
|
@@ -1103,6 +1120,8 @@ export {
|
|
|
1103
1120
|
detectInstalledAgents,
|
|
1104
1121
|
agentFromEnv,
|
|
1105
1122
|
agentFromArgvWithFallback,
|
|
1123
|
+
secureAtomicWrite,
|
|
1124
|
+
defaultConfigPath,
|
|
1106
1125
|
configDir,
|
|
1107
1126
|
DEFAULT_BASE_URL,
|
|
1108
1127
|
DEFAULT_USER_ID,
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
listKnowledgeBases
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-ZW7YXNJ6.js";
|
|
5
5
|
import {
|
|
6
6
|
isConnectionError,
|
|
7
7
|
resetCircuit,
|
|
8
8
|
tripCircuit
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-5DGRNP55.js";
|
|
10
10
|
import {
|
|
11
11
|
CtxdbError,
|
|
12
12
|
isDataApiReady,
|
|
13
13
|
resolveDataApiCredential
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-SVO6H62S.js";
|
|
15
15
|
|
|
16
16
|
// src/lib/kb-catalog.ts
|
|
17
17
|
function sanitizeKeyEntities(raw) {
|