@jini-ai/platform 0.6.15-rc.2 → 0.6.16
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 +26 -0
- package/README.md +7 -8
- package/dist/asset-cache.adapter.d.ts +2 -2
- package/dist/asset-cache.adapter.js +2 -2
- package/dist/asset-cache.d.ts +1 -3
- package/dist/asset-cache.js +2 -14
- package/dist/blob-storage.js +10 -9
- package/dist/child-environment.js +1 -1
- package/dist/command.js +4 -1
- package/dist/core/effective-port.d.ts +6 -0
- package/dist/core/effective-port.js +8 -0
- package/dist/download.js +25 -24
- package/dist/fetch-with-timeout.d.ts +27 -7
- package/dist/fetch-with-timeout.js +51 -11
- package/dist/fs/atomic-write.js +5 -2
- package/dist/fs/boot-identity.d.ts +0 -4
- package/dist/fs/boot-identity.js +9 -3
- package/dist/fs/directory-registry.js +2 -2
- package/dist/fs/directory-running-lock.js +9 -4
- package/dist/fs/directory-sync.js +2 -3
- package/dist/fs/directory-user-config.d.ts +9 -0
- package/dist/fs/directory-user-config.js +13 -6
- package/dist/fs/directory-workspace.js +11 -14
- package/dist/fs/durable-json.js +22 -16
- package/dist/fs/errno.d.ts +9 -0
- package/dist/fs/errno.js +21 -0
- package/dist/fs/file-lock.d.ts +1 -0
- package/dist/fs/file-lock.js +17 -19
- package/dist/fs/guarded-reader.d.ts +22 -1
- package/dist/fs/guarded-reader.js +85 -15
- package/dist/fs/index.d.ts +1 -0
- package/dist/fs/index.js +1 -0
- package/dist/fs/user-data-directories.js +5 -7
- package/dist/fs/workspace-directory.d.ts +7 -1
- package/dist/fs/workspace-directory.js +9 -2
- package/dist/fs.js +2 -1
- package/dist/http/guarded/client.js +2 -1
- package/dist/http.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mail/builtin-providers.js +4 -2
- package/dist/mail/feedback.js +1 -1
- package/dist/mail/prepare-sender-email.js +7 -1
- package/dist/mail/provider.js +14 -8
- package/dist/mail/rate-limit.js +3 -1
- package/dist/messages.js +1 -1
- package/dist/net/endpoint-policy.js +15 -7
- package/dist/net/index.d.ts +1 -0
- package/dist/net/index.js +1 -0
- package/dist/net/local-http-probe.d.ts +16 -0
- package/dist/net/local-http-probe.js +36 -0
- package/dist/object-store/local-fs.js +3 -1
- package/dist/process.js +10 -9
- package/dist/proxy-env.js +22 -14
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.js +14 -3
- package/dist/secrets/credential-sets/vault-archive.d.ts +41 -0
- package/dist/secrets/credential-sets/vault-archive.js +64 -0
- package/dist/secrets/credential-sets/vault.meta.js +6 -2
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
- package/dist/secrets/credential-sets/vault.service.js +22 -17
- package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
- package/dist/secrets/credential-sets/vault.types.js +7 -0
- package/dist/secrets/credential-token.js +9 -2
- package/dist/secrets/keyring.env.js +2 -4
- package/dist/shell.js +2 -1
- package/dist/terminal.js +4 -7
- package/dist/toolchain.js +4 -9
- package/dist/web/first-distinct.d.ts +11 -0
- package/dist/web/first-distinct.js +16 -0
- package/dist/web/html-document.js +35 -39
- package/dist/web/html-to-markdown.js +20 -9
- package/dist/web/web-page.js +28 -25
- package/dist/windows-system-tools.d.ts +15 -0
- package/dist/windows-system-tools.js +16 -0
- package/package.json +10 -6
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { VaultError, isInVaultQueryScope } from "./vault.types.js";
|
|
2
|
+
function assertPortable(kind, principalId) {
|
|
3
|
+
// Personal and MCP credentials require their own identity/configuration transport, outside v1.
|
|
4
|
+
if (principalId !== null || ["personal-execution", "mcp-env", "mcp-auth"].includes(kind))
|
|
5
|
+
throw new VaultError({ code: "context" });
|
|
6
|
+
}
|
|
7
|
+
/** Capture through the owning service's authorization and parsers. Hosts supply exact scopes and
|
|
8
|
+
* attachment-filtered repositories; this helper never discovers other workspaces or principals.
|
|
9
|
+
* Diagnostics contain only a count, because even labels/IDs may contain pasted secrets.
|
|
10
|
+
* @example const captured = await captureVaultArchive({ vault, repo, queries: [siteScope] });
|
|
11
|
+
* @complexity O(rows + payload bytes), bounded to 2,000 rows by the transport contract.
|
|
12
|
+
*/
|
|
13
|
+
export async function captureVaultArchive({ vault, queries, repo }, _options = {}) {
|
|
14
|
+
const entries = [];
|
|
15
|
+
let omitted = 0, count = 0;
|
|
16
|
+
for (const query of queries) {
|
|
17
|
+
assertPortable(query.kind, query.principalId);
|
|
18
|
+
// Optional raw enumeration isolates unsupported metadata; every payload still passes get's
|
|
19
|
+
// exact identity, authorization, parser and AAD checks before it can enter the result.
|
|
20
|
+
for (const row of await (repo ?? vault).list(query)) {
|
|
21
|
+
if (++count > 2000)
|
|
22
|
+
throw new VaultError({ code: "invalid-record" });
|
|
23
|
+
try {
|
|
24
|
+
if (!isInVaultQueryScope({ record: row, query }))
|
|
25
|
+
throw new VaultError({ code: "context" });
|
|
26
|
+
const found = await vault.get(row);
|
|
27
|
+
if (!found)
|
|
28
|
+
throw new VaultError({ code: "conflict" });
|
|
29
|
+
const s = found.summary;
|
|
30
|
+
entries.push({ id: s.id, kind: s.kind, ownerKey: s.ownerKey, binding: s.binding, provider: s.provider,
|
|
31
|
+
label: s.label, isDefault: s.isDefault, meta: s.meta, payload: found.payload });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
omitted++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { entries, notices: omitted ? [`${omitted} saved credential(s) could not be opened and were left out.`] : [] };
|
|
39
|
+
}
|
|
40
|
+
/** Validate and re-seal every entry before the host atomically commits any of them. Keeping the
|
|
41
|
+
* transaction in the host lets settings and vault rows join the same commit. Never call put in a
|
|
42
|
+
* loop: a bad later entry must leave the recipient completely unchanged.
|
|
43
|
+
* @throws VaultError or the host's policy error; callers expose a fixed optional-import notice.
|
|
44
|
+
* @example const records = await prepareVaultArchiveRestore({ vault, workspaceId, entries });
|
|
45
|
+
* @complexity O(entries + payload bytes), at most 2,000 entries.
|
|
46
|
+
*/
|
|
47
|
+
export async function prepareVaultArchiveRestore({ vault, workspaceId, entries }, _options = {}) {
|
|
48
|
+
if (!Array.isArray(entries) || entries.length > 2000)
|
|
49
|
+
throw new VaultError({ code: "invalid-record" });
|
|
50
|
+
const ids = new Set();
|
|
51
|
+
const records = [];
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
assertPortable(entry.kind, null);
|
|
54
|
+
if (ids.has(entry.id))
|
|
55
|
+
throw new VaultError({ code: "invalid-record" });
|
|
56
|
+
ids.add(entry.id);
|
|
57
|
+
// Enumerating fields prevents an archive from supplying a revision, principal or workspace.
|
|
58
|
+
records.push(await vault.prepare({ workspaceId, id: entry.id, kind: entry.kind, ownerKey: entry.ownerKey,
|
|
59
|
+
principalId: null, binding: entry.binding, provider: entry.provider, label: entry.label,
|
|
60
|
+
isDefault: entry.isDefault, meta: entry.meta, payload: entry.payload, expectedRevision: null }));
|
|
61
|
+
}
|
|
62
|
+
return records;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=vault-archive.js.map
|
|
@@ -39,16 +39,20 @@ function isJson(value) {
|
|
|
39
39
|
* @throws VaultError when a supplied subtype or compatibility list is invalid.
|
|
40
40
|
* @complexity O(worksFor characters); returns a fresh metadata object.
|
|
41
41
|
*/
|
|
42
|
+
/** Older vendor rows imply compatibility with their own provider; nothing else implies any. */
|
|
43
|
+
function defaultWorksFor({ credentialType, provider }) {
|
|
44
|
+
return credentialType === "vendor" && provider != null ? [provider] : [];
|
|
45
|
+
}
|
|
42
46
|
function credentialMeta({ meta, kind, provider }) {
|
|
43
47
|
const credentialType = meta.credentialType === undefined ? kind : meta.credentialType;
|
|
44
48
|
if ((credentialType !== "vendor" && credentialType !== "custom") || (kind === "custom" && credentialType !== "custom"))
|
|
45
49
|
throw new VaultError({ code: "invalid-meta" });
|
|
46
|
-
const worksFor = meta.worksFor === undefined ? (credentialType
|
|
50
|
+
const worksFor = meta.worksFor === undefined ? defaultWorksFor({ credentialType, provider }) : meta.worksFor;
|
|
47
51
|
return { ...meta, credentialType, worksFor: normalizeCredentialWorksFor({ value: worksFor }) };
|
|
48
52
|
}
|
|
49
53
|
/** Plain JSON object with identifier-only top-level keys; checked on input and on override output. */
|
|
50
54
|
function requireMetaShape({ value }) {
|
|
51
|
-
if (!isObject(value) || !isJson(value) || Object.keys(value).some(key =>
|
|
55
|
+
if (!isObject(value) || !isJson(value) || Object.keys(value).some(key => !/^\w+$/.test(key)))
|
|
52
56
|
throw new VaultError({ code: "invalid-meta" });
|
|
53
57
|
return value;
|
|
54
58
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertVaultReplacement, vaultGroupKey, VaultError } from "./vault.types.js";
|
|
1
|
+
import { assertVaultReplacement, isInVaultQueryScope, vaultGroupKey, VaultError } from "./vault.types.js";
|
|
2
2
|
/** Single-turn mutations are atomic in JS; clones prevent callers bypassing CAS through aliases. */
|
|
3
3
|
export class InMemoryVaultRepo {
|
|
4
4
|
rows = new Map();
|
|
@@ -56,15 +56,13 @@ export class InMemoryVaultRepo {
|
|
|
56
56
|
/** O(n) memory scan, n stored rows; clones only the default, never its siblings. */
|
|
57
57
|
async findDefault(input, _options = {}) {
|
|
58
58
|
for (const row of this.rows.values()) {
|
|
59
|
-
if (row.isDefault && row.
|
|
60
|
-
row.principalId === input.principalId && row.provider === input.provider)
|
|
59
|
+
if (row.isDefault && row.provider === input.provider && isInVaultQueryScope({ record: row, query: input }))
|
|
61
60
|
return structuredClone(row);
|
|
62
61
|
}
|
|
63
62
|
return null;
|
|
64
63
|
}
|
|
65
64
|
async list(input, _options = {}) {
|
|
66
|
-
return structuredClone([...this.rows.values()].filter(row =>
|
|
67
|
-
row.ownerKey === input.ownerKey && row.principalId === input.principalId && (input.provider === undefined || row.provider === input.provider))
|
|
65
|
+
return structuredClone([...this.rows.values()].filter(row => isInVaultQueryScope({ record: row, query: input }))
|
|
68
66
|
// Rows are keyed by (workspaceId, id), so one workspace never lists two equal IDs.
|
|
69
67
|
.sort((a, b) => a.id < b.id ? -1 : 1));
|
|
70
68
|
}
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { credentialTokenHint } from "../credential-token.js";
|
|
2
2
|
import { buildVaultAad } from "./vault.aad.js";
|
|
3
3
|
import { isCopyableKind } from "./vault.meta.js";
|
|
4
|
-
import { VAULT_KINDS, VaultError } from "./vault.types.js";
|
|
4
|
+
import { VAULT_KINDS, VaultError, isInVaultQueryScope } from "./vault.types.js";
|
|
5
5
|
/** Kinds whose payloads are addresses, environment maps or OAuth sessions, never single tokens. */
|
|
6
6
|
const HINTLESS_KINDS = ["database-destination", "mcp-env", "mcp-auth"];
|
|
7
7
|
function isNonEmptyString({ value }) {
|
|
8
8
|
return typeof value === "string" && value !== "";
|
|
9
9
|
}
|
|
10
|
+
/** The one error for malformed, missing or unsupported records and repo capabilities. */
|
|
11
|
+
function invalidRecord() {
|
|
12
|
+
return new VaultError({ code: "invalid-record" });
|
|
13
|
+
}
|
|
10
14
|
function isNullableString({ value }) {
|
|
11
15
|
return value === null || typeof value === "string";
|
|
12
16
|
}
|
|
13
17
|
function hasValidOwnership({ context }) {
|
|
18
|
+
const validBinding = !("binding" in context) || isNullableString({ value: context.binding });
|
|
14
19
|
return isNonEmptyString({ value: context.workspaceId }) && isNonEmptyString({ value: context.ownerKey }) &&
|
|
15
|
-
isNullableString({ value: context.principalId }) &&
|
|
20
|
+
isNullableString({ value: context.principalId }) && validBinding;
|
|
16
21
|
}
|
|
17
22
|
function hasValidKind({ context }) {
|
|
18
23
|
if (!VAULT_KINDS.some(kind => kind === context.kind) && context.kind !== "custom")
|
|
@@ -25,19 +30,19 @@ function assertContext(context) {
|
|
|
25
30
|
}
|
|
26
31
|
/** Unified credential rows declare both subtype and compatibility; read defaults cover older rows. */
|
|
27
32
|
function declaresCredential({ meta }) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey &&
|
|
32
|
-
record.principalId === query.principalId && (query.provider === undefined || record.provider === query.provider);
|
|
33
|
+
if (meta === null || typeof meta !== "object")
|
|
34
|
+
return false;
|
|
35
|
+
return Object.hasOwn(meta, "credentialType") && Object.hasOwn(meta, "worksFor");
|
|
33
36
|
}
|
|
34
37
|
/** Copy changes the site/key, never the credential's owning feature or endpoint. Principal
|
|
35
38
|
* identities are local to each installation and cannot be carried to the target.
|
|
36
39
|
*/
|
|
37
40
|
function isCopyTarget({ sourceContext, targetContext, sameRepo }) {
|
|
38
|
-
|
|
39
|
-
sourceContext.binding === targetContext.binding
|
|
40
|
-
|
|
41
|
+
const sameFeature = sourceContext.kind === targetContext.kind && sourceContext.ownerKey === targetContext.ownerKey &&
|
|
42
|
+
sourceContext.binding === targetContext.binding;
|
|
43
|
+
const noPrincipal = sourceContext.principalId === null && targetContext.principalId === null;
|
|
44
|
+
const sameSite = sameRepo && sourceContext.workspaceId === targetContext.workspaceId;
|
|
45
|
+
return sameFeature && noPrincipal && !sameSite;
|
|
41
46
|
}
|
|
42
47
|
function sourceNeedsUpgrade({ raw }) {
|
|
43
48
|
return Object.hasOwn(raw.meta, "legacyAad") || (raw.kind === "vendor" && !declaresCredential({ meta: raw.meta }));
|
|
@@ -97,7 +102,7 @@ export class SecretsVaultService {
|
|
|
97
102
|
// JSON round-trip makes the payload contract explicit; encryption never receives an object
|
|
98
103
|
// coerced to '[object Object]' or a connection URI masquerading as an entire payload.
|
|
99
104
|
if (typeof input.payload !== "object" || Array.isArray(input.payload))
|
|
100
|
-
throw
|
|
105
|
+
throw invalidRecord();
|
|
101
106
|
const plaintext = JSON.stringify(input.payload);
|
|
102
107
|
return this.deps.sealer.seal({ plaintext, key: await this.deps.keyring.activeKey({}), aad: buildVaultAad(input.context) });
|
|
103
108
|
}
|
|
@@ -110,10 +115,10 @@ export class SecretsVaultService {
|
|
|
110
115
|
payload = JSON.parse(plaintext);
|
|
111
116
|
}
|
|
112
117
|
catch {
|
|
113
|
-
throw
|
|
118
|
+
throw invalidRecord();
|
|
114
119
|
}
|
|
115
120
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload))
|
|
116
|
-
throw
|
|
121
|
+
throw invalidRecord();
|
|
117
122
|
return payload;
|
|
118
123
|
}
|
|
119
124
|
/** Create/replace configuration and a sealed JSON payload. Throws on policy, validation or CAS failure. */
|
|
@@ -167,7 +172,7 @@ export class SecretsVaultService {
|
|
|
167
172
|
async putEnvelope({ input, identity, existing }) {
|
|
168
173
|
if (input.payload === undefined) {
|
|
169
174
|
if (!existing)
|
|
170
|
-
throw
|
|
175
|
+
throw invalidRecord();
|
|
171
176
|
return { sealed: existing.sealed, last4: existing.last4, tokenLength: existing.tokenLength };
|
|
172
177
|
}
|
|
173
178
|
const sealed = await this.seal({ context: identity, payload: input.payload });
|
|
@@ -195,7 +200,7 @@ export class SecretsVaultService {
|
|
|
195
200
|
assertContext(input);
|
|
196
201
|
await this.authorize({ action: "get", context: input });
|
|
197
202
|
if (!this.deps.repo.findOne)
|
|
198
|
-
throw
|
|
203
|
+
throw invalidRecord();
|
|
199
204
|
const record = await this.deps.repo.findOne(input);
|
|
200
205
|
if (!record)
|
|
201
206
|
return null;
|
|
@@ -213,7 +218,7 @@ export class SecretsVaultService {
|
|
|
213
218
|
await this.authorize({ action: "get", context: input });
|
|
214
219
|
// Old injected ports retain their existing operations; default reads require this capability.
|
|
215
220
|
if (!this.deps.repo.findDefault)
|
|
216
|
-
throw
|
|
221
|
+
throw invalidRecord();
|
|
217
222
|
const record = await this.deps.repo.findDefault(input);
|
|
218
223
|
if (!record)
|
|
219
224
|
return null;
|
|
@@ -301,7 +306,7 @@ export class SecretsVaultService {
|
|
|
301
306
|
assertContext(input);
|
|
302
307
|
await this.authorize({ action: "list", context: input });
|
|
303
308
|
return (await this.deps.repo.list(input)).map(record => {
|
|
304
|
-
if (!
|
|
309
|
+
if (!isInVaultQueryScope({ record, query: input }))
|
|
305
310
|
throw new VaultError({ code: "context" });
|
|
306
311
|
return summary(this.parse(record));
|
|
307
312
|
});
|
|
@@ -152,6 +152,12 @@ export interface VaultRepoPort {
|
|
|
152
152
|
expectedRevision: number;
|
|
153
153
|
}, options?: VaultOptions): Promise<boolean>;
|
|
154
154
|
}
|
|
155
|
+
/** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
|
|
156
|
+
* matches every provider. @complexity O(1). */
|
|
157
|
+
export declare function isInVaultQueryScope({ record, query }: {
|
|
158
|
+
record: Pick<VaultRecord, keyof VaultQuery>;
|
|
159
|
+
query: VaultQuery;
|
|
160
|
+
}, _options?: VaultOptions): boolean;
|
|
155
161
|
/** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
|
|
156
162
|
export declare function vaultGroupKey({ record }: {
|
|
157
163
|
record: VaultDefaultQuery;
|
|
@@ -9,6 +9,13 @@ export class VaultError extends Error {
|
|
|
9
9
|
this.name = "VaultError";
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
|
|
13
|
+
* matches every provider. @complexity O(1). */
|
|
14
|
+
export function isInVaultQueryScope({ record, query }, _options = {}) {
|
|
15
|
+
const sameOwner = record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey;
|
|
16
|
+
const sameProvider = query.provider === undefined || record.provider === query.provider;
|
|
17
|
+
return sameOwner && record.principalId === query.principalId && sameProvider;
|
|
18
|
+
}
|
|
12
19
|
/** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
|
|
13
20
|
export function vaultGroupKey({ record }, _options = {}) {
|
|
14
21
|
return JSON.stringify([record.workspaceId, record.kind, record.ownerKey, record.principalId, record.provider]);
|
|
@@ -60,10 +60,17 @@ export function credentialTokenHint({ token }, _optional = {}) {
|
|
|
60
60
|
return { length: token.length, last4: token.length >= 12 ? token.slice(-4) : null };
|
|
61
61
|
}
|
|
62
62
|
const SECRET_NAME = /(?:^|[_-])(?:key|api[_-]?key|token|access[_-]?token|secret|password|auth|sig)$/i;
|
|
63
|
-
|
|
63
|
+
// A `--flag` followed by a value; the flag name is captured whole and its suffix checked separately,
|
|
64
|
+
// so the name run and the keyword can no longer backtrack against each other.
|
|
65
|
+
const CLI_FLAG_WITH_VALUE = /(?:^|\s)--([\w-]*)(?=(?:=|\s+)\S)/gi;
|
|
66
|
+
const SECRET_FLAG_SUFFIX = /(?:api[_-]?key|token|secret|password)$/i;
|
|
67
|
+
/** True when some `--…api-key/token/secret/password` flag carries a value. @complexity O(value length). */
|
|
68
|
+
function hasSecretCliArg(value) {
|
|
69
|
+
return [...value.matchAll(CLI_FLAG_WITH_VALUE)].some(match => SECRET_FLAG_SUFFIX.test(match[1]));
|
|
70
|
+
}
|
|
64
71
|
/** Protect plaintext metadata, using Jini's detector plus structural URL/CLI rules. */
|
|
65
72
|
export function assertCredentialFreeField({ value, field, containsSecret }, _optional = {}) {
|
|
66
|
-
let credential = containsSecret({ text: value }) ||
|
|
73
|
+
let credential = containsSecret({ text: value }) || hasSecretCliArg(value);
|
|
67
74
|
try {
|
|
68
75
|
const url = new URL(value);
|
|
69
76
|
credential ||= url.username !== '' || url.password !== '' || [...url.searchParams.keys()].some(key => SECRET_NAME.test(key));
|
|
@@ -206,8 +206,7 @@ function toActiveRootKeyMaterial(source, raw) {
|
|
|
206
206
|
* @example inspectRootKeyMaterial({ env, envVarName, keyFilePath })
|
|
207
207
|
*/
|
|
208
208
|
export function inspectRootKeyMaterial(options) {
|
|
209
|
-
const envVarName = options
|
|
210
|
-
const keyFilePath = options.keyFilePath;
|
|
209
|
+
const { envVarName, keyFilePath } = options;
|
|
211
210
|
const raw = readActiveRootKeyMaterial({ env: options.env, envVarName, keyFilePath });
|
|
212
211
|
if (raw.hex)
|
|
213
212
|
return { active: true, source: raw.source, fingerprint: fingerprintRootKeyHex({ hex: raw.hex }), keyFilePath };
|
|
@@ -221,8 +220,7 @@ function invalidFields(raw) {
|
|
|
221
220
|
* @example revealRootKeyMaterial({ env, envVarName, keyFilePath })
|
|
222
221
|
*/
|
|
223
222
|
export function revealRootKeyMaterial(options) {
|
|
224
|
-
const envVarName = options
|
|
225
|
-
const keyFilePath = options.keyFilePath;
|
|
223
|
+
const { envVarName, keyFilePath } = options;
|
|
226
224
|
const raw = readActiveRootKeyMaterial({ env: options.env, envVarName, keyFilePath });
|
|
227
225
|
if (raw.hex)
|
|
228
226
|
return { active: true, source: raw.source, hex: raw.hex, fingerprint: fingerprintRootKeyHex({ hex: raw.hex }), keyFilePath };
|
package/dist/shell.js
CHANGED
|
@@ -41,7 +41,8 @@ export function execFileBuffered(command, args, opts = {}) {
|
|
|
41
41
|
/** @internal Single-quote a POSIX shell argument, escaping embedded single quotes. */
|
|
42
42
|
function quotePosixShellArg(value) {
|
|
43
43
|
const text = String(value ?? "");
|
|
44
|
-
|
|
44
|
+
const escapedQuote = "'\\''";
|
|
45
|
+
return `'${text.replace(/'/g, escapedQuote)}'`;
|
|
45
46
|
}
|
|
46
47
|
/** @internal Join a command and its arguments into one quoted POSIX shell command line. */
|
|
47
48
|
function buildShellCommandLine(command, args) {
|
package/dist/terminal.js
CHANGED
|
@@ -171,7 +171,8 @@ export function createTerminalService(options) {
|
|
|
171
171
|
}
|
|
172
172
|
};
|
|
173
173
|
const emit = (session, event, data) => {
|
|
174
|
-
const id = session.nextEventId
|
|
174
|
+
const id = session.nextEventId;
|
|
175
|
+
session.nextEventId += 1;
|
|
175
176
|
const byteLength = recordByteLength(data);
|
|
176
177
|
const record = { id, event, data, timestamp: Date.now(), byteLength };
|
|
177
178
|
session.events.push(record);
|
|
@@ -282,12 +283,8 @@ export function createTerminalService(options) {
|
|
|
282
283
|
return session ? toPublicSession(session) : null;
|
|
283
284
|
};
|
|
284
285
|
const list = (filter = {}) => Array.from(sessions.values())
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return false;
|
|
288
|
-
}
|
|
289
|
-
return true;
|
|
290
|
-
})
|
|
286
|
+
// Only a non-empty string projectId filters; anything else lists every session.
|
|
287
|
+
.filter((session) => !(typeof filter.projectId === 'string' && filter.projectId && session.projectId !== filter.projectId))
|
|
291
288
|
.map(toPublicSession);
|
|
292
289
|
const attach = (id, lastEventId, sink) => {
|
|
293
290
|
const session = sessions.get(id);
|
package/dist/toolchain.js
CHANGED
|
@@ -255,18 +255,13 @@ function compareVersionLikeDirNames(left, right) {
|
|
|
255
255
|
const leftSemver = parseVersionLikeDirName(left);
|
|
256
256
|
const rightSemver = parseVersionLikeDirName(right);
|
|
257
257
|
if (leftSemver && rightSemver) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (difference !== 0)
|
|
261
|
-
return difference;
|
|
262
|
-
}
|
|
258
|
+
const difference = leftSemver.map((part, index) => rightSemver[index] - part).find((value) => value !== 0);
|
|
259
|
+
return difference ?? left.localeCompare(right);
|
|
263
260
|
}
|
|
264
|
-
|
|
261
|
+
if (leftSemver)
|
|
265
262
|
return -1;
|
|
266
|
-
|
|
267
|
-
else if (rightSemver) {
|
|
263
|
+
if (rightSemver)
|
|
268
264
|
return 1;
|
|
269
|
-
}
|
|
270
265
|
return left.localeCompare(right);
|
|
271
266
|
}
|
|
272
267
|
/** @internal Parse a `vX.Y.Z` / `X.Y.Z` directory name into its numeric parts, or `null` when it does not match. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Items with distinct non-empty keys, in order, capped at `max`; `truncated` reports a further
|
|
2
|
+
* distinct key beyond the cap (scanning stops there). @complexity O(items). */
|
|
3
|
+
export declare function firstDistinct<T>({ items, max, keyOf }: {
|
|
4
|
+
items: readonly T[];
|
|
5
|
+
max: number;
|
|
6
|
+
keyOf: (item: T) => string | null | undefined;
|
|
7
|
+
}): {
|
|
8
|
+
picked: [string, T][];
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=first-distinct.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Items with distinct non-empty keys, in order, capped at `max`; `truncated` reports a further
|
|
2
|
+
* distinct key beyond the cap (scanning stops there). @complexity O(items). */
|
|
3
|
+
export function firstDistinct({ items, max, keyOf }) {
|
|
4
|
+
const picked = new Map();
|
|
5
|
+
const truncated = items.some((item) => {
|
|
6
|
+
const key = keyOf(item);
|
|
7
|
+
if (!key || picked.has(key))
|
|
8
|
+
return false;
|
|
9
|
+
if (picked.size === max)
|
|
10
|
+
return true;
|
|
11
|
+
picked.set(key, item);
|
|
12
|
+
return false;
|
|
13
|
+
});
|
|
14
|
+
return { picked: [...picked], truncated };
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=first-distinct.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parse, serialize } from "parse5";
|
|
2
|
+
import { firstDistinct } from "./first-distinct.js";
|
|
2
3
|
export const MAX_LINKS = 500;
|
|
3
4
|
export const MAX_IMAGES = 300;
|
|
4
5
|
const MAX_STYLESHEETS = 100;
|
|
@@ -88,19 +89,25 @@ const COLLECTED = new Map([["a", "anchors"], ["img", "images"], ["link", "linkTa
|
|
|
88
89
|
function collect(document) {
|
|
89
90
|
const raw = { anchors: [], images: [], linkTags: [], metas: [] };
|
|
90
91
|
for (const node of walk(document)) {
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
const list = COLLECTED.get(node.tagName);
|
|
94
|
-
if (list)
|
|
95
|
-
raw[list].push(node);
|
|
96
|
-
else if (node.tagName === "title" || node.tagName === "html")
|
|
97
|
-
raw[node.tagName] ??= node;
|
|
98
|
-
// The first base WITH an href wins, as in browsers.
|
|
99
|
-
else if (node.tagName === "base")
|
|
100
|
-
raw.base ??= attr({ element: node, name: "href" });
|
|
92
|
+
if (isElement(node))
|
|
93
|
+
collectElement(raw, node);
|
|
101
94
|
}
|
|
102
95
|
return raw;
|
|
103
96
|
}
|
|
97
|
+
function collectElement(raw, node) {
|
|
98
|
+
const list = COLLECTED.get(node.tagName);
|
|
99
|
+
if (list) {
|
|
100
|
+
raw[list].push(node);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (node.tagName === "title" || node.tagName === "html") {
|
|
104
|
+
raw[node.tagName] ??= node;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// The first base WITH an href wins, as in browsers.
|
|
108
|
+
if (node.tagName === "base")
|
|
109
|
+
raw.base ??= attr({ element: node, name: "href" });
|
|
110
|
+
}
|
|
104
111
|
/** Meta keys whose values are URLs and are returned absolute. */
|
|
105
112
|
const URL_META = new Set(["og:image", "og:url", "twitter:image"]);
|
|
106
113
|
/** `<link rel>` tokens copied into meta, in emission order, under their meta key. */
|
|
@@ -127,11 +134,16 @@ function putMeta({ meta, key, value }, _optional = {}) {
|
|
|
127
134
|
return;
|
|
128
135
|
meta[key] = collapse(value).slice(0, MAX_META_VALUE);
|
|
129
136
|
}
|
|
137
|
+
function metaKey({ property, name }) {
|
|
138
|
+
if (property?.startsWith("og:"))
|
|
139
|
+
return property;
|
|
140
|
+
return name && META_NAMES.has(name) ? name : undefined;
|
|
141
|
+
}
|
|
130
142
|
/** Every `og:*` property, or an allowlisted `name`; URL-valued keys are resolved. */
|
|
131
143
|
function metaTagEntry({ element, base }, _optional = {}) {
|
|
132
144
|
const property = attr({ element, name: "property" })?.toLowerCase();
|
|
133
145
|
const name = attr({ element, name: "name" })?.toLowerCase();
|
|
134
|
-
const key =
|
|
146
|
+
const key = metaKey({ property, name });
|
|
135
147
|
if (key === undefined)
|
|
136
148
|
return undefined;
|
|
137
149
|
const content = attr({ element, name: "content" });
|
|
@@ -141,37 +153,21 @@ function relTokens({ element }, _optional = {}) {
|
|
|
141
153
|
return (attr({ element, name: "rel" }) ?? "").toLowerCase().split(/\s+/);
|
|
142
154
|
}
|
|
143
155
|
function readLinks(anchors, base, pageHost) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
continue;
|
|
151
|
-
seen.add(href);
|
|
152
|
-
if (links.length === MAX_LINKS) {
|
|
153
|
-
truncated = true;
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
links.push({ href, text: linkText(anchor), internal: isInternal(href, pageHost) });
|
|
157
|
-
}
|
|
156
|
+
const { picked, truncated } = firstDistinct({
|
|
157
|
+
items: anchors,
|
|
158
|
+
max: MAX_LINKS,
|
|
159
|
+
keyOf: (anchor) => absolute({ raw: attr({ element: anchor, name: "href" }), base, protocols: LINK_PROTOCOLS }),
|
|
160
|
+
});
|
|
161
|
+
const links = picked.map(([href, anchor]) => ({ href, text: linkText(anchor), internal: isInternal(href, pageHost) }));
|
|
158
162
|
return { links, truncated };
|
|
159
163
|
}
|
|
160
164
|
function readImages(images, base) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
continue;
|
|
168
|
-
seen.add(src);
|
|
169
|
-
if (out.length === MAX_IMAGES) {
|
|
170
|
-
truncated = true;
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
out.push({ src, alt: collapse(attr({ element: img, name: "alt" }) ?? "") });
|
|
174
|
-
}
|
|
165
|
+
const { picked, truncated } = firstDistinct({
|
|
166
|
+
items: images,
|
|
167
|
+
max: MAX_IMAGES,
|
|
168
|
+
keyOf: (img) => absolute({ raw: imageSource({ img }), base, protocols: ASSET_PROTOCOLS }),
|
|
169
|
+
});
|
|
170
|
+
const out = picked.map(([src, img]) => ({ src, alt: collapse(attr({ element: img, name: "alt" }) ?? "") }));
|
|
175
171
|
return { images: out, truncated };
|
|
176
172
|
}
|
|
177
173
|
function readStylesheets(linkTags, base) {
|
|
@@ -12,18 +12,25 @@ const MAX_DEPTH = 300;
|
|
|
12
12
|
function isElement(node) {
|
|
13
13
|
return "tagName" in node;
|
|
14
14
|
}
|
|
15
|
+
/** A node's children for {@link plainText}; skipped elements and leaves contribute none. */
|
|
16
|
+
function scannableChildren(node) {
|
|
17
|
+
if (!("childNodes" in node) || (isElement(node) && SKIPPED.has(node.tagName)))
|
|
18
|
+
return [];
|
|
19
|
+
return node.childNodes;
|
|
20
|
+
}
|
|
15
21
|
/** Iterative so a degraded, very deep subtree cannot overflow the stack either. O(subtree nodes). */
|
|
16
22
|
function plainText(root) {
|
|
17
23
|
const pending = [root];
|
|
18
24
|
const parts = [];
|
|
19
25
|
while (pending.length) {
|
|
20
26
|
const node = pending.pop();
|
|
21
|
-
if (node.nodeName === "#text" && "value" in node)
|
|
27
|
+
if (node.nodeName === "#text" && "value" in node) {
|
|
22
28
|
parts.push(node.value);
|
|
23
|
-
|
|
24
|
-
for (let i = node.childNodes.length - 1; i >= 0; i--)
|
|
25
|
-
pending.push(node.childNodes[i]);
|
|
29
|
+
continue;
|
|
26
30
|
}
|
|
31
|
+
const children = scannableChildren(node);
|
|
32
|
+
for (let i = children.length - 1; i >= 0; i--)
|
|
33
|
+
pending.push(children[i]);
|
|
27
34
|
}
|
|
28
35
|
return parts.join("").replace(/\s+/g, " ");
|
|
29
36
|
}
|
|
@@ -112,9 +119,11 @@ function takeEdgeWhitespace({ node, last }, _options = {}) {
|
|
|
112
119
|
const edge = edgeText({ node, last });
|
|
113
120
|
if (!edge)
|
|
114
121
|
return "";
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
const value = edge.value;
|
|
123
|
+
// trimStart/trimEnd strip exactly the characters `\s` matches.
|
|
124
|
+
const kept = last ? value.trimEnd() : value.trimStart();
|
|
125
|
+
edge.value = kept;
|
|
126
|
+
return last ? value.slice(kept.length) : value.slice(0, value.length - kept.length);
|
|
118
127
|
}
|
|
119
128
|
/** The first/last leaf of the subtree when it is a text node. */
|
|
120
129
|
function edgeText({ node, last }, _options = {}) {
|
|
@@ -141,9 +150,11 @@ function hastText(root) {
|
|
|
141
150
|
while (pending.length) {
|
|
142
151
|
const node = pending.pop();
|
|
143
152
|
// HAST text nodes always carry a value.
|
|
144
|
-
if (node.type === "text")
|
|
153
|
+
if (node.type === "text") {
|
|
145
154
|
parts.push(node.value);
|
|
146
|
-
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (node.children)
|
|
147
158
|
for (let i = node.children.length - 1; i >= 0; i--)
|
|
148
159
|
pending.push(node.children[i]);
|
|
149
160
|
}
|
package/dist/web/web-page.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { boundHtml } from "./html-resource.js";
|
|
2
|
+
import { firstDistinct } from "./first-distinct.js";
|
|
2
3
|
import { ToolInputError } from "@jini-ai/core";
|
|
3
4
|
import { EgressRefusedError } from "../http/guarded/errors.js";
|
|
4
5
|
import { htmlToText, readHtmlDocument, stripActiveHtml, MAX_LINKS, MAX_IMAGES } from "./html-document.js";
|
|
@@ -145,28 +146,25 @@ const EMPTY_FACTS = { links: [], linksTruncated: false, images: [], imagesTrunca
|
|
|
145
146
|
/** A sitemap's `<loc>` entries become links so an importer can enumerate pages from one call. */
|
|
146
147
|
function sitemapFacts(xml, pageUrl) {
|
|
147
148
|
const host = new URL(pageUrl).hostname.replace(/^www\./, "");
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
url = new URL(match[1].replace(/&/g, "&"), pageUrl);
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if ((url.protocol !== "http:" && url.protocol !== "https:") || seen.has(url.href))
|
|
160
|
-
continue;
|
|
161
|
-
seen.add(url.href);
|
|
162
|
-
if (links.length === MAX_LINKS) {
|
|
163
|
-
linksTruncated = true;
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
links.push({ href: url.href, text: "", internal: url.hostname.replace(/^www\./, "") === host });
|
|
167
|
-
}
|
|
149
|
+
const { picked, truncated: linksTruncated } = firstDistinct({
|
|
150
|
+
items: [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)],
|
|
151
|
+
max: MAX_LINKS,
|
|
152
|
+
keyOf: (match) => sitemapHref(match[1], pageUrl),
|
|
153
|
+
});
|
|
154
|
+
const links = picked.map(([href]) => ({ href, text: "", internal: new URL(href).hostname.replace(/^www\./, "") === host }));
|
|
168
155
|
return { ...EMPTY_FACTS, links, linksTruncated };
|
|
169
156
|
}
|
|
157
|
+
/** The absolute http(s) href of one `<loc>` value, or undefined when it is unparsable or another scheme. */
|
|
158
|
+
function sitemapHref(loc, pageUrl) {
|
|
159
|
+
let url;
|
|
160
|
+
try {
|
|
161
|
+
url = new URL(loc.replace(/&/g, "&"), pageUrl);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined;
|
|
167
|
+
}
|
|
170
168
|
function render(format, body, isHtml, pageUrl, ports) {
|
|
171
169
|
if (!isHtml || format === "raw")
|
|
172
170
|
return body;
|
|
@@ -364,9 +362,9 @@ function projectRendered({ input, ports, url, page }) {
|
|
|
364
362
|
}
|
|
365
363
|
/** Any clipped or dropped fact, image, data or text marks the whole rendered result truncated. */
|
|
366
364
|
function renderedFlags({ page, result, images, factsTruncated, textTruncated }) {
|
|
367
|
-
const imagesTruncated = images.truncated
|
|
368
|
-
const truncated = imagesTruncated
|
|
369
|
-
|
|
365
|
+
const imagesTruncated = [images.truncated, page.imagesTruncated, result.imagesTruncated, images.byUrl.size > MAX_IMAGES].some(Boolean);
|
|
366
|
+
const truncated = [imagesTruncated, factsTruncated, page.title.length > MAX_RENDERED_TITLE, result.truncated,
|
|
367
|
+
result.linksTruncated, page.dataTruncated, textTruncated].some(Boolean);
|
|
370
368
|
return { imagesTruncated, truncated };
|
|
371
369
|
}
|
|
372
370
|
/** Browser image facts come first, so their alt text wins over the parsed DOM's for the same src. */
|
|
@@ -391,8 +389,13 @@ function boundRenderedFacts({ result }, _optional = {}) {
|
|
|
391
389
|
const stylesheets = result.stylesheets.filter(href => href.length <= MAX_URL_LENGTH);
|
|
392
390
|
const meta = Object.fromEntries(Object.entries(result.meta).filter(([key]) => key.length <= MAX_RENDERED_META_KEY));
|
|
393
391
|
const linksDropped = links.length < result.links.length;
|
|
394
|
-
const truncated =
|
|
395
|
-
|
|
392
|
+
const truncated = [
|
|
393
|
+
linksDropped,
|
|
394
|
+
stylesheets.length < result.stylesheets.length,
|
|
395
|
+
Object.keys(meta).length < Object.keys(result.meta).length,
|
|
396
|
+
(result.description?.length ?? 0) > MAX_RENDERED_DESCRIPTION,
|
|
397
|
+
(result.lang?.length ?? 0) > MAX_RENDERED_LANG,
|
|
398
|
+
].some(Boolean);
|
|
396
399
|
return { links, stylesheets, meta, linksDropped, truncated };
|
|
397
400
|
}
|
|
398
401
|
/** `none` empties content; `summary` and text format use the browser's visible text instead of the projection. */
|