@animalabs/agent-framework 0.22.3 → 0.23.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/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* not fetch third-party origins at all (deferred with declared reference
|
|
9
9
|
* origins); the connection credential is applied as an Authorization
|
|
10
10
|
* header by this code alone and never surfaces to model, script, or URI.
|
|
11
|
-
* - **scheme allowlist (§7.1, vector 11):**
|
|
12
|
-
*
|
|
11
|
+
* - **scheme allowlist (§7.1, vector 11):** the uri's own scheme is http: or
|
|
12
|
+
* https:, and since origins compare with their schemes, http only from a
|
|
13
|
+
* server dialed in plaintext (ws://), https only from one dialed over TLS
|
|
14
|
+
* (wss://).
|
|
13
15
|
* - **no redirect traversal (§7.2, vector 12):** any 3xx refuses.
|
|
14
16
|
* - **actual-byte ceiling (§7.3 list, vector 10):** streamed with abort the
|
|
15
17
|
* moment real octets exceed the ceiling, regardless of claimed sizeBytes.
|
|
@@ -30,9 +32,11 @@
|
|
|
30
32
|
*/
|
|
31
33
|
import { createHash } from 'node:crypto';
|
|
32
34
|
import type { ReferenceRecord } from './references.js';
|
|
33
|
-
import {
|
|
35
|
+
import { boundedForLog, describeCause } from './errors.js';
|
|
36
|
+
import { EXPIRY_REASON, noAddressReason, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel, UNBOUND_REASON } from './references.js';
|
|
34
37
|
|
|
35
|
-
/** Per-fetch hard ceiling on actual bytes
|
|
38
|
+
/** Per-fetch hard ceiling on actual bytes. A call can lower it for itself
|
|
39
|
+
* (fetch_reference's max_bytes, which it clamps to this); nothing raises it. */
|
|
36
40
|
export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
|
|
37
41
|
/** Eager (autofetch) threshold: at most what could have shipped inline. */
|
|
38
42
|
export const DEFAULT_EAGER_FETCH_MAX_BYTES = 256 * 1024;
|
|
@@ -41,29 +45,120 @@ export const DEFAULT_EAGER_FETCH_MAX_BYTES = 256 * 1024;
|
|
|
41
45
|
* kept. */
|
|
42
46
|
export const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
|
|
43
47
|
const FETCH_TIMEOUT_MS = 60_000;
|
|
44
|
-
/** What a refusal by the per-server budget adds. The total lives as long as
|
|
45
|
-
* this fetcher, one per framework, so only someone running the host renews
|
|
46
|
-
* it, by starting a new one, or raises the budget (the server's config's
|
|
47
|
-
* autofetch.maxTotalBytes, which each fetch reads afresh). */
|
|
48
|
-
const BUDGET_LASTS = "the budget doesn't renew on its own, so calling again won't change that unless whoever runs this host raises or renews it";
|
|
49
48
|
const EAGER_FETCH_TIMEOUT_MS = 10_000;
|
|
50
49
|
|
|
50
|
+
// What calling again can do, as each failure's remedy says it. The fetcher
|
|
51
|
+
// doesn't know where a reference came from (a tool result, a push, a channel
|
|
52
|
+
// message), so none names a tool, and fetch_reference's max_bytes is named
|
|
53
|
+
// only where a larger one can fetch it.
|
|
54
|
+
const NOTHING_TO_DO = "Calling again won't change that.";
|
|
55
|
+
/** The registry keys a record by its uri, so a fresh reference to the same
|
|
56
|
+
* uri arrives as this very id and refreshes its record. */
|
|
57
|
+
const FRESH_REFERENCE = "Once a fresh reference to it arrives, fetching may work; until then, fetching it again won't.";
|
|
58
|
+
const TRY_LATER = 'Trying again later may work.';
|
|
59
|
+
/** A request that failed before an answer: a refused or reset connection
|
|
60
|
+
* can clear, and a cause that repeats on every try (a port fetch refuses, a
|
|
61
|
+
* name that doesn't resolve, a certificate this host doesn't trust) is
|
|
62
|
+
* whoever runs the host's to change, so the remedy names both. */
|
|
63
|
+
const REQUEST_FAILED = "Trying again later may work; if it fails the same way, whoever runs this host can check its connection to the server.";
|
|
64
|
+
const CREDENTIALS = "Calling again won't change that; whoever runs this host can check its credentials for the server.";
|
|
65
|
+
const FRESH_OR_CREDENTIALS = "Once a fresh reference to it arrives, fetching may work; if that's refused too, whoever runs this host can check its credentials for the server.";
|
|
66
|
+
/** The total lives as long as this fetcher, one per framework, so only
|
|
67
|
+
* someone running the host renews it, by starting a new one, or raises the
|
|
68
|
+
* budget (the server's config's autofetch.maxTotalBytes, which each fetch
|
|
69
|
+
* reads afresh). */
|
|
70
|
+
const BUDGET = "The budget doesn't renew on its own, so calling again won't change that unless whoever runs this host raises or renews it.";
|
|
71
|
+
/** The host's own ceiling: fetch_reference clamps max_bytes to it, and no
|
|
72
|
+
* config sets another. */
|
|
73
|
+
const HOST_CEILING = "No max_bytes or setting lifts that limit, so calling again won't change it.";
|
|
74
|
+
/** The resolver reads the host's server configs as they are at each fetch. */
|
|
75
|
+
const SERVER_BACK = "Calling again won't change that unless whoever runs this host adds that server back.";
|
|
76
|
+
|
|
51
77
|
export interface ResolvedServerBinding {
|
|
52
|
-
/** The url the
|
|
78
|
+
/** The url the host dials the server at. The framework's resolver gives only
|
|
79
|
+
* a ws:// or wss:// url its WebSocket transport dials; the origin check
|
|
80
|
+
* reads ws as http and wss as https. */
|
|
53
81
|
url: string;
|
|
54
82
|
/** Connection credential, applied host-side as Authorization: Bearer. */
|
|
55
83
|
token?: string;
|
|
56
84
|
autofetch?: { maxBytes?: number; maxTotalBytes?: number };
|
|
57
85
|
}
|
|
58
86
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
/** What the host knows of a server a reference came from: its binding, or
|
|
88
|
+
* 'no-address' for one the host doesn't reach over a network, such as a
|
|
89
|
+
* stdio server, which has no address to dial. A server the host doesn't
|
|
90
|
+
* know resolves to undefined. */
|
|
91
|
+
export type ServerResolution = ResolvedServerBinding | 'no-address';
|
|
92
|
+
|
|
93
|
+
/** Why a fetch failed, or would be refused before dialing. */
|
|
94
|
+
export interface Refusal {
|
|
95
|
+
/** What happened, as a clause that completes "couldn't fetch <ref>: ",
|
|
96
|
+
* whatever it's about: fetch_reference's sentence, save_recent_image's,
|
|
97
|
+
* and the eager and script-demand fetches' log lines read it. */
|
|
98
|
+
error: string;
|
|
99
|
+
/** What calling again can do, as a sentence. fetch_reference and
|
|
100
|
+
* save_recent_image show it; the eager and script-demand fetches log the
|
|
101
|
+
* error alone. */
|
|
102
|
+
remedy: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A fetch's result: the saved copy, or why it failed, which every failure
|
|
106
|
+
* says with both a clause and a remedy. The fields the other side lacks are
|
|
107
|
+
* declared undefined, so a caller can read them before checking ok. */
|
|
108
|
+
export type FetchOutcome =
|
|
109
|
+
| {
|
|
110
|
+
ok: true;
|
|
111
|
+
/** Mount-prefixed workspace path of the saved payload. */
|
|
112
|
+
path: string;
|
|
113
|
+
bytes?: number;
|
|
114
|
+
mimeType?: string;
|
|
115
|
+
digestVerified?: boolean;
|
|
116
|
+
error?: undefined;
|
|
117
|
+
remedy?: undefined;
|
|
118
|
+
}
|
|
119
|
+
| ({ ok: false; path?: undefined; bytes?: undefined; mimeType?: undefined; digestVerified?: undefined } & Refusal);
|
|
120
|
+
|
|
121
|
+
const refused = (error: string, remedy: string): { refusal: Refusal } => ({ refusal: { error, remedy } });
|
|
122
|
+
const fail = (error: string, remedy: string): FetchOutcome => ({ ok: false, error, remedy });
|
|
123
|
+
|
|
124
|
+
/** What calling again can do about a server's HTTP status. A later try can
|
|
125
|
+
* work after a timeout, a conflict or lock the server's state can clear, a
|
|
126
|
+
* request it calls too early, a rate limit or a server's passing trouble,
|
|
127
|
+
* though not after 501 or 505, which say the server doesn't support the
|
|
128
|
+
* request. A 401 is about this host's credentials for the server, which a
|
|
129
|
+
* fresh reference doesn't change, and a 403 can be either those or this
|
|
130
|
+
* reference. The rest of 4xx is about this reference, which a fresh one may
|
|
131
|
+
* not share. */
|
|
132
|
+
function statusRemedy(status: number): string {
|
|
133
|
+
if (status === 501 || status === 505) return NOTHING_TO_DO;
|
|
134
|
+
if (status === 408 || status === 409 || status === 423 || status === 425 || status === 429 || status >= 500) return TRY_LATER;
|
|
135
|
+
if (status === 401) return CREDENTIALS;
|
|
136
|
+
if (status === 403) return FRESH_OR_CREDENTIALS;
|
|
137
|
+
return FRESH_REFERENCE;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a download stopped at a call's own ceiling, below the host's, can do.
|
|
141
|
+
* Its whole size is unknown, only that it's past what arrived, and a fetch
|
|
142
|
+
* again reads it from the start, within the host's ceiling and what the
|
|
143
|
+
* server's budget has left after this one's bytes. The read loop stops a
|
|
144
|
+
* download past the host's ceiling before it asks, so what arrived is within
|
|
145
|
+
* it, and only the budget can be what binds. */
|
|
146
|
+
function largerMaxBytes(received: number, left: number, server: string): string {
|
|
147
|
+
const most = Math.min(DEFAULT_FETCH_MAX_BYTES, left);
|
|
148
|
+
if (received <= most) return `It's at least ${received} bytes; a larger max_bytes, up to ${most}, may fetch it.`;
|
|
149
|
+
return `It's at least ${received} bytes, more than the ${left} bytes left of this host's fetch budget for ${server}. ${BUDGET}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What a thrown fetch says went wrong. fetch's own "fetch failed" says only
|
|
153
|
+
* that, so its cause's words come first. A cause from trying each of a
|
|
154
|
+
* host's addresses (an AggregateError) has no words of its own, so its
|
|
155
|
+
* attempts' stand in. */
|
|
156
|
+
function whyFetchFailed(e: unknown): string {
|
|
157
|
+
const source = (e as { cause?: unknown } | null | undefined)?.cause ?? e;
|
|
158
|
+
const own = describeCause(source);
|
|
159
|
+
if (own !== '') return own;
|
|
160
|
+
const attempts = (source as { errors?: unknown } | null | undefined)?.errors;
|
|
161
|
+
return Array.isArray(attempts) && attempts.length > 0 ? attempts.map(describeCause).join('; ') : describeCause(e);
|
|
67
162
|
}
|
|
68
163
|
|
|
69
164
|
/** Magic-byte sniff for the formats this pipeline actually moves. The
|
|
@@ -96,6 +191,15 @@ const EXT_BY_MIME = new Map<string, string>(Object.entries({
|
|
|
96
191
|
'application/pdf': 'pdf', 'application/zip': 'zip',
|
|
97
192
|
}));
|
|
98
193
|
|
|
194
|
+
/** A server's address as it's configured, its scheme and host, for a
|
|
195
|
+
* clause: never its userinfo, path or query. URL's own origin is 'null' for
|
|
196
|
+
* a scheme that isn't special, which normalizedOrigin accepts. The caller
|
|
197
|
+
* has already parsed it. */
|
|
198
|
+
function configuredOrigin(url: string): string {
|
|
199
|
+
const u = new URL(url);
|
|
200
|
+
return `${u.protocol}//${u.host}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
99
203
|
/** ws→http / wss→https with default ports elided, for origin comparison. */
|
|
100
204
|
export function normalizedOrigin(url: string): string | null {
|
|
101
205
|
try {
|
|
@@ -118,56 +222,148 @@ export class ReferenceFetcher {
|
|
|
118
222
|
private inFlight = new Map<string, Promise<FetchOutcome>>();
|
|
119
223
|
|
|
120
224
|
constructor(
|
|
121
|
-
private readonly resolveServer: (serverId: string) =>
|
|
225
|
+
private readonly resolveServer: (serverId: string) => ServerResolution | undefined,
|
|
122
226
|
/** Persist the payload; returns a mount-prefixed path or null when no
|
|
123
227
|
* writable workspace exists. File name is host-generated (§7.3). */
|
|
124
228
|
private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
|
|
125
229
|
) {}
|
|
126
230
|
|
|
127
|
-
/** Would the eager (autofetch) default fetch this record? Not one the
|
|
128
|
-
*
|
|
129
|
-
*
|
|
231
|
+
/** Would the eager (autofetch) default fetch this record? Not one the fetch
|
|
232
|
+
* would refuse before dialing, asked as the eager fetch asks it, with the
|
|
233
|
+
* default ceiling, so such a record takes no eager slot; and only one whose
|
|
234
|
+
* claimed size is within the eager limit of the server it dials. For one
|
|
235
|
+
* whose fetch is already under way, which the eager fetch would join as
|
|
236
|
+
* fetch() does, only the server and its eager limit enter: the expiry,
|
|
237
|
+
* the budget and the ceiling were checked when that fetch began. */
|
|
130
238
|
isEagerEligible(record: ReferenceRecord): boolean {
|
|
131
239
|
if (record.fetchedPath) return false;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const cap = server.binding.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
|
|
240
|
+
const check = this.inFlight.has(record.refId) ? this.serverFor(record) : this.beforeDial(record);
|
|
241
|
+
if ('refusal' in check) return false;
|
|
242
|
+
const cap = check.binding.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
|
|
136
243
|
return record.testimony.sizeBytes !== undefined && record.testimony.sizeBytes <= cap;
|
|
137
244
|
}
|
|
138
245
|
|
|
139
246
|
/** The server to dial for a record (§6.2): the first, in the order they
|
|
140
247
|
* delivered it, whose address is on the uri's origin. When none is, the
|
|
141
|
-
* first server's refusal (unknown to this host,
|
|
142
|
-
* and for a record no server delivered, that it's
|
|
143
|
-
|
|
144
|
-
|
|
248
|
+
* first server's refusal (unknown to this host, with no address to dial,
|
|
249
|
+
* or the origin check's), and for a record no server delivered, that it's
|
|
250
|
+
* unbound. */
|
|
251
|
+
private serverFor(record: ReferenceRecord): { serverId: string; binding: ResolvedServerBinding } | { refusal: Refusal } {
|
|
252
|
+
let refusal: { refusal: Refusal } | undefined;
|
|
145
253
|
for (const serverId of record.serverIds) {
|
|
146
254
|
const binding = this.resolveServer(serverId);
|
|
147
255
|
if (!binding) {
|
|
148
|
-
refusal ??= `
|
|
256
|
+
refusal ??= refused(`this host no longer has the "${serverId}" server it came from`, SERVER_BACK);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (binding === 'no-address') {
|
|
260
|
+
refusal ??= refused(noAddressReason(serverId), NOTHING_TO_DO);
|
|
149
261
|
continue;
|
|
150
262
|
}
|
|
151
|
-
const offOrigin = this.originCheck(record, binding);
|
|
263
|
+
const offOrigin = this.originCheck(record, binding, serverId);
|
|
152
264
|
if (offOrigin === null) return { serverId, binding };
|
|
153
265
|
refusal ??= offOrigin;
|
|
154
266
|
}
|
|
155
|
-
|
|
267
|
+
// A later delivery of the same uri by a server binds this record.
|
|
268
|
+
return refusal ?? refused(`it's ${UNBOUND_REASON}`, FRESH_REFERENCE);
|
|
156
269
|
}
|
|
157
270
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
271
|
+
/** Every check the fetch makes before it dials, in its order: the server
|
|
272
|
+
* (none, unknown, no address, an address of its own or the reference's
|
|
273
|
+
* that doesn't parse, a reference address carrying a user name or
|
|
274
|
+
* password, a scheme other than http: or https:, off the uri's origin),
|
|
275
|
+
* the expiry, a claimed size over the host's own ceiling, the server's
|
|
276
|
+
* spent budget, a claimed size over a call's ceiling. For a record with no
|
|
277
|
+
* saved copy and no fetch under way, the fetch, `refusal()` and the eager
|
|
278
|
+
* check all ask this, so what save_recent_image says is the reason the
|
|
279
|
+
* fetch would refuse with, and no eager slot goes to a fetch that would be
|
|
280
|
+
* refused. */
|
|
281
|
+
private beforeDial(record: ReferenceRecord, opts?: { maxBytes?: number }):
|
|
282
|
+
{ refusal: Refusal } | { serverId: string; binding: ResolvedServerBinding; ceiling: number; budget: number } {
|
|
283
|
+
const server = this.serverFor(record);
|
|
284
|
+
if ('refusal' in server) return server;
|
|
285
|
+
const { serverId, binding } = server;
|
|
286
|
+
const t = record.testimony;
|
|
287
|
+
const expiry = referenceExpiry(t);
|
|
288
|
+
if (expiry) return refused(`the reference is ${EXPIRY_REASON[expiry]}`, FRESH_REFERENCE);
|
|
289
|
+
|
|
290
|
+
const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
|
|
291
|
+
// A claim past the host's own ceiling is refused first: nothing lifts
|
|
292
|
+
// it, not even a budget whoever runs the host raises.
|
|
293
|
+
if (t.sizeBytes !== undefined && t.sizeBytes > DEFAULT_FETCH_MAX_BYTES) {
|
|
294
|
+
return refused(`it claims ${t.sizeBytes} bytes, over the ${DEFAULT_FETCH_MAX_BYTES} bytes this host fetches at most`, HOST_CEILING);
|
|
295
|
+
}
|
|
296
|
+
// The budget is checked before a call's ceiling, here and while reading:
|
|
297
|
+
// a larger max_bytes lifts that ceiling, and nothing the caller does lifts
|
|
298
|
+
// the budget, so a fetch past both is told about the budget.
|
|
299
|
+
const budget = binding.autofetch?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
|
300
|
+
const used = this.totals.get(serverId) ?? 0;
|
|
301
|
+
if (used >= budget) {
|
|
302
|
+
return refused(`this host's fetch budget for the "${serverId}" server is used up (${used} of ${budget} bytes)`, BUDGET);
|
|
303
|
+
}
|
|
304
|
+
// Vector 4: a claim already over the ceiling refuses without dialing. One
|
|
305
|
+
// that's also over what's left of the budget is told about the budget,
|
|
306
|
+
// which no max_bytes lifts. A claim over what's left but within the
|
|
307
|
+
// ceiling still dials, and the budget stops it at its first chunk past.
|
|
308
|
+
if (t.sizeBytes !== undefined && t.sizeBytes > ceiling) {
|
|
309
|
+
if (t.sizeBytes > budget - used) {
|
|
310
|
+
return refused(
|
|
311
|
+
`it claims ${t.sizeBytes} bytes, more than the ${budget - used} bytes left of this host's fetch budget for the "${serverId}" server`,
|
|
312
|
+
BUDGET,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
// Within what's left of the budget and the host's ceiling, so a
|
|
316
|
+
// ceiling of the call's own is what a larger max_bytes lifts.
|
|
317
|
+
return refused(`it claims ${t.sizeBytes} bytes, over the ${ceiling} bytes this call allows`,
|
|
318
|
+
`Pass max_bytes of at least ${t.sizeBytes} to fetch it.`);
|
|
319
|
+
}
|
|
320
|
+
return { serverId, binding, ceiling, budget };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Why a fetch of this record would be refused before dialing, with the
|
|
324
|
+
* default ceiling, which a fetch_reference call naming no max_bytes has.
|
|
325
|
+
* Null where the fetch would serve a saved copy, join a fetch of it
|
|
326
|
+
* already under way, or dial (where it can still fail), the same three
|
|
327
|
+
* steps fetch() takes. */
|
|
328
|
+
refusal(record: ReferenceRecord): Refusal | null {
|
|
329
|
+
if (record.fetchedPath || this.inFlight.has(record.refId)) return null;
|
|
330
|
+
const check = this.beforeDial(record);
|
|
331
|
+
return 'refusal' in check ? check.refusal : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** §6.1/§7.1 policy check; returns a refusal or null. */
|
|
335
|
+
private originCheck(record: ReferenceRecord, binding: ResolvedServerBinding, serverId: string): { refusal: Refusal } | null {
|
|
160
336
|
const refOrigin = normalizedOrigin(record.testimony.uri);
|
|
337
|
+
if (!refOrigin) return refused("its address doesn't parse as a URL", NOTHING_TO_DO);
|
|
338
|
+
// A server's address that doesn't parse leaves nothing to dial. The
|
|
339
|
+
// framework's resolver gives only an address its transport dials, so this
|
|
340
|
+
// guards other resolvers.
|
|
161
341
|
const dialedOrigin = normalizedOrigin(binding.url);
|
|
162
|
-
if (!
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (scheme !== 'https:' && scheme !== 'http:') return `scheme not allowed: ${scheme}`;
|
|
166
|
-
if (refOrigin !== dialedOrigin) {
|
|
167
|
-
return `third-party origin (${refOrigin} != dialed ${dialedOrigin}); not fetched by this host`;
|
|
342
|
+
if (!dialedOrigin) {
|
|
343
|
+
return refused(`the "${serverId}" server's address doesn't parse as a URL, so there's nowhere to fetch it from`,
|
|
344
|
+
"Calling again won't change that unless whoever runs this host corrects the server's address.");
|
|
168
345
|
}
|
|
169
|
-
|
|
170
|
-
|
|
346
|
+
const refUrl = new URL(record.testimony.uri);
|
|
347
|
+
// A user name or password in the uri is a credential embedded in it,
|
|
348
|
+
// which RFC-005 §6.1 forbids a server to mint and lets a host refuse
|
|
349
|
+
// (vector 6). This host neither sends nor repeats it: fetch refuses such
|
|
350
|
+
// a url with a message that holds it whole.
|
|
351
|
+
if (refUrl.username !== '' || refUrl.password !== '') {
|
|
352
|
+
return refused("its address carries a user name or password, which this host doesn't send", NOTHING_TO_DO);
|
|
353
|
+
}
|
|
354
|
+
// The uri's own scheme, not its origin's: an origin reads ws: as http:
|
|
355
|
+
// and wss: as https:, and fetch refuses a ws: or wss: uri ('unknown
|
|
356
|
+
// scheme') before connecting, which would read as a failed request a
|
|
357
|
+
// later try can mend.
|
|
358
|
+
const scheme = refUrl.protocol;
|
|
359
|
+
if (scheme !== 'https:' && scheme !== 'http:') {
|
|
360
|
+
return refused(`its address uses ${scheme}, and this host fetches only http: and https: addresses`, NOTHING_TO_DO);
|
|
361
|
+
}
|
|
362
|
+
// Origins compare with their schemes, so this also refuses an http:
|
|
363
|
+
// reference from a server dialed over TLS (wss://).
|
|
364
|
+
if (refOrigin !== dialedOrigin) {
|
|
365
|
+
return refused(`it points to ${refOrigin}, not to the "${serverId}" server's own address (${configuredOrigin(binding.url)}), ` +
|
|
366
|
+
'and this host fetches only from the server itself', NOTHING_TO_DO);
|
|
171
367
|
}
|
|
172
368
|
return null;
|
|
173
369
|
}
|
|
@@ -188,46 +384,23 @@ export class ReferenceFetcher {
|
|
|
188
384
|
|
|
189
385
|
private async doFetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
|
|
190
386
|
const t = record.testimony;
|
|
191
|
-
const
|
|
192
|
-
if ('refusal' in
|
|
193
|
-
const { serverId, binding } =
|
|
194
|
-
const
|
|
195
|
-
if (expiry) return { ok: false, error: `reference ${EXPIRY_REASON[expiry]}` };
|
|
196
|
-
|
|
197
|
-
const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
|
|
198
|
-
// The budget is checked before the ceiling, here and while reading: a
|
|
199
|
-
// larger max_bytes lifts the ceiling, and nothing the caller does lifts
|
|
200
|
-
// the budget, so a fetch past both is told about the budget.
|
|
201
|
-
const budget = binding.autofetch?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
|
202
|
-
const used = this.totals.get(serverId) ?? 0;
|
|
203
|
-
if (used >= budget) {
|
|
204
|
-
return { ok: false, error: `per-server fetch budget exhausted (${used}/${budget} bytes); ${BUDGET_LASTS}` };
|
|
205
|
-
}
|
|
206
|
-
// Vector 4: a claim already over the ceiling refuses without dialing. One
|
|
207
|
-
// that's also over what's left of the budget is told about the budget,
|
|
208
|
-
// which no max_bytes lifts. A claim over what's left but within the
|
|
209
|
-
// ceiling still dials, and the budget stops it at its first chunk past.
|
|
210
|
-
if (t.sizeBytes !== undefined && t.sizeBytes > ceiling) {
|
|
211
|
-
if (t.sizeBytes > budget - used) {
|
|
212
|
-
return {
|
|
213
|
-
ok: false,
|
|
214
|
-
error: `claimed ${t.sizeBytes} bytes exceeds what's left of the per-server fetch budget (${used}/${budget} bytes); ${BUDGET_LASTS}`,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
return { ok: false, error: `claimed ${t.sizeBytes} bytes exceeds fetch ceiling ${ceiling}` };
|
|
218
|
-
}
|
|
387
|
+
const check = this.beforeDial(record, opts);
|
|
388
|
+
if ('refusal' in check) return { ok: false, ...check.refusal };
|
|
389
|
+
const { serverId, binding, ceiling, budget } = check;
|
|
390
|
+
const server = `the "${serverId}" server`;
|
|
219
391
|
|
|
392
|
+
const timeoutMs = opts?.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
220
393
|
const controller = new AbortController();
|
|
221
|
-
const timer = setTimeout(() => controller.abort(),
|
|
394
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
222
395
|
try {
|
|
223
396
|
const headers: Record<string, string> = {};
|
|
224
397
|
if (binding.token) headers.authorization = `Bearer ${binding.token}`;
|
|
225
398
|
const resp = await fetch(t.uri, { headers, redirect: 'manual', signal: controller.signal });
|
|
226
399
|
if (resp.status >= 300 && resp.status < 400) {
|
|
227
|
-
return {
|
|
400
|
+
return fail(`${server} answered HTTP ${resp.status}, a redirect, and this host doesn't follow redirects`, NOTHING_TO_DO);
|
|
228
401
|
}
|
|
229
|
-
if (!resp.ok) return {
|
|
230
|
-
if (!resp.body) return {
|
|
402
|
+
if (!resp.ok) return fail(`${server} answered HTTP ${resp.status}`, statusRemedy(resp.status));
|
|
403
|
+
if (!resp.body) return fail(`${server} answered without any content`, FRESH_REFERENCE);
|
|
231
404
|
|
|
232
405
|
const hash = createHash('sha256');
|
|
233
406
|
const chunks: Buffer[] = [];
|
|
@@ -241,13 +414,24 @@ export class ReferenceFetcher {
|
|
|
241
414
|
// this server read the same total, so none spends what another took.
|
|
242
415
|
const total = (this.totals.get(serverId) ?? 0) + value.byteLength;
|
|
243
416
|
this.totals.set(serverId, total);
|
|
417
|
+
// The limit nothing lifts first, as before dialing: the host's
|
|
418
|
+
// ceiling, then the budget, then a call's own ceiling.
|
|
419
|
+
if (received > DEFAULT_FETCH_MAX_BYTES) {
|
|
420
|
+
controller.abort();
|
|
421
|
+
return fail(`the download had reached ${received} bytes, past the ${DEFAULT_FETCH_MAX_BYTES} bytes this host fetches at most, ` +
|
|
422
|
+
'so it was stopped and nothing was kept', HOST_CEILING);
|
|
423
|
+
}
|
|
244
424
|
if (total > budget) {
|
|
245
425
|
controller.abort();
|
|
246
|
-
|
|
426
|
+
// The server's running total, as the next fetch's refusal gives it:
|
|
427
|
+
// other fetches through the server may have spent part of it.
|
|
428
|
+
return fail(`this host's fetch budget for ${server} ran out during the download (${total} of ${budget} bytes), ` +
|
|
429
|
+
'so it was stopped and nothing was kept', BUDGET);
|
|
247
430
|
}
|
|
248
431
|
if (received > ceiling) {
|
|
249
432
|
controller.abort();
|
|
250
|
-
return {
|
|
433
|
+
return fail(`the download had reached ${received} bytes, past the ${ceiling} bytes this call allows, ` +
|
|
434
|
+
'so it was stopped and nothing was kept', largerMaxBytes(received, budget - total, server));
|
|
251
435
|
}
|
|
252
436
|
const buf = Buffer.from(value);
|
|
253
437
|
hash.update(buf);
|
|
@@ -260,7 +444,11 @@ export class ReferenceFetcher {
|
|
|
260
444
|
const got = 'sha256:' + hash.digest('base64url');
|
|
261
445
|
if (got !== t.digest) {
|
|
262
446
|
// Vector 5: never presented as the described content.
|
|
263
|
-
return
|
|
447
|
+
return fail(
|
|
448
|
+
`the bytes that arrived don't match the reference's digest (${t.digest.slice(0, 20)}…, against ${got.slice(0, 20)}…), ` +
|
|
449
|
+
"so they weren't kept",
|
|
450
|
+
"Trying again may work; if it fails the same way, fetching may work once a fresh reference to it arrives.",
|
|
451
|
+
);
|
|
264
452
|
}
|
|
265
453
|
digestVerified = true;
|
|
266
454
|
}
|
|
@@ -275,8 +463,21 @@ export class ReferenceFetcher {
|
|
|
275
463
|
const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
|
|
276
464
|
const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
|
|
277
465
|
// §7.3: host-generated storage name; the server's `name` is display-only.
|
|
278
|
-
|
|
279
|
-
|
|
466
|
+
// A null from the save hook covers no workspace, no writable mount and
|
|
467
|
+
// a failed write, and the hook logs which. A throw is the save's failure
|
|
468
|
+
// too, not the fetch's, so it's logged here.
|
|
469
|
+
const fileName = `${record.refId}.${ext}`;
|
|
470
|
+
let path: string | null;
|
|
471
|
+
try {
|
|
472
|
+
path = await this.saveFile(fileName, data, observedMime);
|
|
473
|
+
} catch (e) {
|
|
474
|
+
console.error(`[refs] couldn't store ${fileName}: ${boundedForLog(describeCause(e))}`);
|
|
475
|
+
path = null;
|
|
476
|
+
}
|
|
477
|
+
if (path === null) {
|
|
478
|
+
return fail("it was fetched, but this host couldn't store it in its workspace, so nothing was kept",
|
|
479
|
+
'Calling again is likely to fail the same way; whoever runs this host can see why in its log.');
|
|
480
|
+
}
|
|
280
481
|
|
|
281
482
|
record.fetchedPath = path;
|
|
282
483
|
record.verifiedBytes = data.length;
|
|
@@ -284,8 +485,13 @@ export class ReferenceFetcher {
|
|
|
284
485
|
record.digestVerified = digestVerified;
|
|
285
486
|
return { ok: true, path, bytes: data.length, mimeType: observedMime, digestVerified };
|
|
286
487
|
} catch (e) {
|
|
287
|
-
|
|
288
|
-
|
|
488
|
+
// The timer is the only abort that throws: the budget and the ceiling
|
|
489
|
+
// abort the download and return at once.
|
|
490
|
+
if ((e as Error)?.name === 'AbortError') {
|
|
491
|
+
const seconds = timeoutMs / 1000;
|
|
492
|
+
return fail(`it took longer than ${seconds} second${seconds === 1 ? '' : 's'}`, TRY_LATER);
|
|
493
|
+
}
|
|
494
|
+
return fail(`the request to ${server} failed: ${boundedForLog(whyFetchFailed(e))}`, REQUEST_FAILED);
|
|
289
495
|
} finally {
|
|
290
496
|
clearTimeout(timer);
|
|
291
497
|
}
|