@retinue/agentkit 0.2.0 → 0.3.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.
Files changed (204) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/audio/openai.d.ts +36 -0
  3. package/dist/adapters/audio/openai.js +136 -0
  4. package/dist/adapters/bullmq/lock.d.ts +26 -2
  5. package/dist/adapters/memory/connections.d.ts +10 -0
  6. package/dist/adapters/memory/connections.js +87 -0
  7. package/dist/adapters/memory/graph.d.ts +17 -0
  8. package/dist/adapters/memory/graph.js +267 -0
  9. package/dist/adapters/memory/index.d.ts +3 -0
  10. package/dist/adapters/memory/index.js +3 -0
  11. package/dist/adapters/memory/rate-limit.d.ts +14 -0
  12. package/dist/adapters/memory/rate-limit.js +28 -0
  13. package/dist/adapters/memory/runtime.js +0 -0
  14. package/dist/adapters/postgres/connections.d.ts +15 -0
  15. package/dist/adapters/postgres/connections.js +134 -0
  16. package/dist/adapters/postgres/graph.d.ts +24 -0
  17. package/dist/adapters/postgres/graph.js +325 -0
  18. package/dist/adapters/postgres/index.d.ts +2 -0
  19. package/dist/adapters/postgres/index.js +2 -0
  20. package/dist/adapters/postgres/migrations.js +198 -0
  21. package/dist/adapters/postgres/run-store.js +26 -2
  22. package/dist/adapters/postgres/schema.d.ts +61 -2
  23. package/dist/adapters/postgres/schema.js +95 -10
  24. package/dist/adapters/redis/index.d.ts +1 -0
  25. package/dist/adapters/redis/index.js +1 -0
  26. package/dist/adapters/redis/rate-limit.d.ts +46 -0
  27. package/dist/adapters/redis/rate-limit.js +55 -0
  28. package/dist/adapters/supabase/index.d.ts +15 -0
  29. package/dist/adapters/supabase/index.js +15 -0
  30. package/dist/adapters/supabase/rls.js +33 -0
  31. package/dist/agents/agent.d.ts +78 -1
  32. package/dist/agents/agent.js +118 -5
  33. package/dist/agents/define.js +24 -4
  34. package/dist/agents/engine.d.ts +20 -0
  35. package/dist/agents/engine.js +177 -6
  36. package/dist/audio/index.d.ts +151 -0
  37. package/dist/audio/index.js +183 -0
  38. package/dist/connections/cipher.d.ts +103 -0
  39. package/dist/connections/cipher.js +141 -0
  40. package/dist/connections/index.d.ts +156 -0
  41. package/dist/connections/index.js +72 -0
  42. package/dist/connections/oauth/client.d.ts +84 -0
  43. package/dist/connections/oauth/client.js +141 -0
  44. package/dist/connections/oauth/index.d.ts +166 -0
  45. package/dist/connections/oauth/index.js +226 -0
  46. package/dist/connections/oauth/service.d.ts +90 -0
  47. package/dist/connections/oauth/service.js +132 -0
  48. package/dist/connections/pause.d.ts +68 -0
  49. package/dist/connections/pause.js +97 -0
  50. package/dist/connections/resolver.d.ts +59 -0
  51. package/dist/connections/resolver.js +138 -0
  52. package/dist/connections/resume.d.ts +64 -0
  53. package/dist/connections/resume.js +99 -0
  54. package/dist/connections/vault-cipher.d.ts +95 -0
  55. package/dist/connections/vault-cipher.js +206 -0
  56. package/dist/core/content-parts.d.ts +18 -2
  57. package/dist/core/content-parts.js +1 -0
  58. package/dist/core/context.d.ts +25 -0
  59. package/dist/core/errors.d.ts +1 -1
  60. package/dist/core/errors.js +15 -0
  61. package/dist/core/events.d.ts +44 -2
  62. package/dist/core/events.js +31 -2
  63. package/dist/core/validation.js +19 -0
  64. package/dist/entries/connections.d.ts +10 -0
  65. package/dist/entries/connections.js +10 -0
  66. package/dist/entries/knowledge.d.ts +8 -0
  67. package/dist/entries/knowledge.js +8 -0
  68. package/dist/entries/mcp-server.d.ts +9 -0
  69. package/dist/entries/mcp-server.js +9 -0
  70. package/dist/entries/runtime.d.ts +10 -0
  71. package/dist/entries/runtime.js +8 -0
  72. package/dist/entries/testing.d.ts +28 -0
  73. package/dist/entries/testing.js +28 -0
  74. package/dist/graphql/resolvers.d.ts +3 -1
  75. package/dist/graphql/resolvers.js +14 -0
  76. package/dist/knowledge/communities.d.ts +166 -0
  77. package/dist/knowledge/communities.js +377 -0
  78. package/dist/knowledge/graph-global.d.ts +153 -0
  79. package/dist/knowledge/graph-global.js +246 -0
  80. package/dist/knowledge/graph-retrieval.d.ts +150 -0
  81. package/dist/knowledge/graph-retrieval.js +303 -0
  82. package/dist/knowledge/graph.d.ts +223 -0
  83. package/dist/knowledge/graph.js +369 -0
  84. package/dist/knowledge/index.d.ts +24 -0
  85. package/dist/knowledge/index.js +36 -1
  86. package/dist/knowledge/retrieval.d.ts +26 -1
  87. package/dist/knowledge/retrieval.js +77 -1
  88. package/dist/mcp-server/index.d.ts +168 -0
  89. package/dist/mcp-server/index.js +175 -0
  90. package/dist/models/extraction.d.ts +73 -0
  91. package/dist/models/extraction.js +118 -0
  92. package/dist/models/index.d.ts +40 -1
  93. package/dist/models/index.js +22 -1
  94. package/dist/models/pricing.d.ts +2 -0
  95. package/dist/models/pricing.js +17 -1
  96. package/dist/models/provider-factory.js +9 -7
  97. package/dist/models/streaming.d.ts +89 -0
  98. package/dist/models/streaming.js +179 -2
  99. package/dist/persistence/index.d.ts +230 -0
  100. package/dist/persistence/index.js +2 -0
  101. package/dist/runtime/index.d.ts +1 -1
  102. package/dist/runtime/index.js +12 -0
  103. package/dist/runtime/worker.d.ts +36 -1
  104. package/dist/runtime/worker.js +37 -4
  105. package/dist/security/checklist.js +46 -0
  106. package/dist/server/bin.d.ts +19 -0
  107. package/dist/server/bin.js +226 -0
  108. package/dist/server/boot.d.ts +20 -2
  109. package/dist/server/boot.js +29 -3
  110. package/dist/server/cli.d.ts +13 -0
  111. package/dist/server/cli.js +7 -4
  112. package/dist/server/doctor.d.ts +74 -0
  113. package/dist/server/doctor.js +280 -0
  114. package/dist/skills/context.d.ts +61 -0
  115. package/dist/skills/context.js +107 -0
  116. package/dist/skills/index.d.ts +1 -0
  117. package/dist/skills/index.js +1 -0
  118. package/dist/telemetry/spans.js +5 -0
  119. package/dist/testing/conformance/artifact-exports.d.ts +20 -0
  120. package/dist/testing/conformance/artifact-exports.js +172 -0
  121. package/dist/testing/conformance/artifacts.d.ts +11 -0
  122. package/dist/testing/conformance/artifacts.js +316 -0
  123. package/dist/testing/conformance/audio.d.ts +38 -0
  124. package/dist/testing/conformance/audio.js +145 -0
  125. package/dist/testing/conformance/capability.d.ts +23 -0
  126. package/dist/testing/conformance/capability.js +27 -0
  127. package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
  128. package/dist/testing/conformance/checkpoint-store.js +72 -0
  129. package/dist/testing/conformance/connections.d.ts +14 -0
  130. package/dist/testing/conformance/connections.js +171 -0
  131. package/dist/testing/conformance/conversation-store.d.ts +10 -0
  132. package/dist/testing/conformance/conversation-store.js +60 -0
  133. package/dist/testing/conformance/evaluation.d.ts +10 -0
  134. package/dist/testing/conformance/evaluation.js +295 -0
  135. package/dist/testing/conformance/files.d.ts +19 -0
  136. package/dist/testing/conformance/files.js +454 -0
  137. package/dist/testing/conformance/flows.d.ts +16 -0
  138. package/dist/testing/conformance/flows.js +193 -0
  139. package/dist/testing/conformance/graph.d.ts +22 -0
  140. package/dist/testing/conformance/graph.js +500 -0
  141. package/dist/testing/conformance/hitl.d.ts +25 -0
  142. package/dist/testing/conformance/hitl.js +523 -0
  143. package/dist/testing/conformance/index.d.ts +163 -0
  144. package/dist/testing/conformance/index.js +321 -0
  145. package/dist/testing/conformance/invariants.d.ts +23 -0
  146. package/dist/testing/conformance/invariants.js +80 -0
  147. package/dist/testing/conformance/knowledge.d.ts +41 -0
  148. package/dist/testing/conformance/knowledge.js +592 -0
  149. package/dist/testing/conformance/parents.d.ts +50 -0
  150. package/dist/testing/conformance/parents.js +39 -0
  151. package/dist/testing/conformance/rate-limit.d.ts +25 -0
  152. package/dist/testing/conformance/rate-limit.js +71 -0
  153. package/dist/testing/conformance/records.d.ts +36 -0
  154. package/dist/testing/conformance/records.js +400 -0
  155. package/dist/testing/conformance/rollups.d.ts +30 -0
  156. package/dist/testing/conformance/rollups.js +425 -0
  157. package/dist/testing/conformance/run-coordinator.d.ts +27 -0
  158. package/dist/testing/conformance/run-coordinator.js +150 -0
  159. package/dist/testing/conformance/run-event-log.d.ts +9 -0
  160. package/dist/testing/conformance/run-event-log.js +138 -0
  161. package/dist/testing/conformance/run-store.d.ts +12 -0
  162. package/dist/testing/conformance/run-store.js +294 -0
  163. package/dist/testing/conformance/session-state.d.ts +38 -0
  164. package/dist/testing/conformance/session-state.js +198 -0
  165. package/dist/testing/conformance/usage-limits.d.ts +10 -0
  166. package/dist/testing/conformance/usage-limits.js +309 -0
  167. package/dist/testing/conformance.d.ts +8 -0
  168. package/dist/testing/conformance.js +8 -0
  169. package/dist/testing/memory-backend.d.ts +39 -0
  170. package/dist/testing/memory-backend.js +41 -0
  171. package/dist/testing/pglite.d.ts +55 -0
  172. package/dist/testing/pglite.js +78 -0
  173. package/dist/testing/stub-model.d.ts +58 -0
  174. package/dist/testing/stub-model.js +71 -0
  175. package/dist/testing/supabase-storage-double.d.ts +35 -0
  176. package/dist/testing/supabase-storage-double.js +101 -0
  177. package/dist/toolkit/http.d.ts +27 -0
  178. package/dist/toolkit/http.js +48 -0
  179. package/dist/toolkit/index.d.ts +27 -1
  180. package/dist/toolkit/index.js +24 -1
  181. package/dist/toolkit/mime.d.ts +97 -0
  182. package/dist/toolkit/mime.js +233 -0
  183. package/dist/toolkit/ssrf.d.ts +139 -0
  184. package/dist/toolkit/ssrf.js +349 -0
  185. package/dist/toolkit/vendor.d.ts +91 -0
  186. package/dist/toolkit/vendor.js +153 -0
  187. package/dist/tools/credentials.d.ts +265 -6
  188. package/dist/tools/credentials.js +318 -3
  189. package/dist/tools/define.d.ts +14 -0
  190. package/dist/tools/define.js +3 -0
  191. package/dist/tools/index.d.ts +21 -0
  192. package/dist/tools/index.js +7 -0
  193. package/dist/tools/library/audio.d.ts +63 -0
  194. package/dist/tools/library/audio.js +148 -0
  195. package/dist/tools/library/index.d.ts +8 -0
  196. package/dist/tools/library/index.js +7 -0
  197. package/dist/tools/registry.d.ts +33 -0
  198. package/dist/tools/registry.js +72 -2
  199. package/dist/usage/index.d.ts +21 -0
  200. package/dist/usage/index.js +1 -0
  201. package/dist/usage/rate-limit.d.ts +122 -0
  202. package/dist/usage/rate-limit.js +131 -0
  203. package/dist/usage/recorder.js +3 -0
  204. package/package.json +29 -2
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5.
2
+ * Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5; widened by REQ-063 (#259), #260.
3
3
  *
4
- * A toolkit needs a token. The question is who holds it and when it is read, and getting that wrong is the
4
+ * A toolkit needs a secret. The question is who holds it and when it is read, and getting that wrong is the
5
5
  * decision that makes a multi-tenant deployment a rewrite of every package rather than a configuration change.
6
6
  *
7
7
  * ## Why a tool must not read the environment
@@ -27,14 +27,81 @@
27
27
  *
28
28
  * Returning an empty string would send an unauthenticated request and surface as a vendor 401 several layers
29
29
  * away, where the actual problem — nobody wired the credential — is invisible. A typed failure names the ref.
30
+ *
31
+ * ## Why a string was not enough — #260
32
+ *
33
+ * `resolve()` returned `Promise<string>`, which covers a bearer token and nothing else. Jira and Confluence
34
+ * want an account email *and* an API token as Basic auth; Atlassian's OAuth needs an access token *and* a cloud
35
+ * id discovered after consent; WhatsApp needs a token *and* a phone number id; a vendor using `X-Api-Key` needs
36
+ * a header name. Four of the fourteen integrations specified in `docs/23` cannot be expressed as a string, and
37
+ * every one of them would have grown its own side-channel.
38
+ *
39
+ * Two axes, deliberately kept apart, because they are answered by different people:
40
+ *
41
+ * - **`CredentialScheme`** — how the secret is presented on the wire. The toolkit knows this.
42
+ * - **`AuthMode`** — how the tenant *obtained* it: pasted a token, or completed an OAuth flow. The deployment
43
+ * knows this, and it is what decides whether an unconnected tool can pause a run for consent (#264) or must
44
+ * simply fail, since a token has no login URL to redirect to.
30
45
  */
31
46
  import type { ExecutionContext } from "../core/context.js";
32
- import type { PlatformError } from "../core/errors.js";
47
+ import { type PlatformError } from "../core/errors.js";
33
48
  /** An opaque handle. Its meaning belongs to the resolver, and no tool interprets it. */
34
49
  export type CredentialRef = string;
50
+ /** How a secret is presented on the wire. */
51
+ export declare const CREDENTIAL_SCHEMES: readonly ["bearer", "basic", "custom-header"];
52
+ export type CredentialScheme = (typeof CREDENTIAL_SCHEMES)[number];
53
+ /**
54
+ * How a tenant supplied the credential.
55
+ *
56
+ * Not the same question as the scheme: an OAuth access token is presented as a bearer, so the two would collapse
57
+ * if they shared a type — and the collapse would lose exactly the fact #264 needs, which is whether there is a
58
+ * login URL to send someone to.
59
+ */
60
+ export declare const AUTH_MODES: readonly ["token", "oauth2"];
61
+ export type AuthMode = (typeof AUTH_MODES)[number];
62
+ /**
63
+ * Non-secret vendor identifiers that travel with the credential.
64
+ *
65
+ * Atlassian's cloud id, WhatsApp's phone number id, Slack's team id — discovered at connection time, needed on
66
+ * every request, and **not secrets**. They live here rather than in a toolkit's configuration because they are
67
+ * per *connection*: two tenants using the same toolkit have different ones, which is the same reason the token
68
+ * is not configuration either.
69
+ */
70
+ export type CredentialMetadata = Readonly<Record<string, string>>;
71
+ type WithMetadata = {
72
+ readonly metadata?: CredentialMetadata;
73
+ readonly mode?: AuthMode;
74
+ };
75
+ export type Credential = (WithMetadata & {
76
+ readonly scheme: "bearer";
77
+ readonly token: string;
78
+ }) | (WithMetadata & {
79
+ readonly scheme: "basic";
80
+ readonly username: string;
81
+ readonly password: string;
82
+ }) | (WithMetadata & {
83
+ readonly scheme: "custom-header";
84
+ readonly header: string;
85
+ readonly value: string;
86
+ });
87
+ /**
88
+ * Builds a credential whose secret is **not enumerable** — AC-7.
89
+ *
90
+ * A typed object is far more likely to reach a log line than a bare string was: it gets spread into an error's
91
+ * `details`, passed to a structured logger, or serialised into an audit row, and every one of those uses
92
+ * `JSON.stringify` or an object spread. So the secret is defined non-enumerably and `toJSON`/`toString`/
93
+ * `util.inspect` are overridden. `credential.token` still reads normally; `{ ...credential }`,
94
+ * `JSON.stringify(credential)` and `console.log(credential)` do not.
95
+ *
96
+ * This is defence in depth, not a licence: a caller that reads `.token` and logs *that* is still logging a
97
+ * secret, and no type can stop it.
98
+ */
99
+ export declare const createCredential: (input: Credential) => Credential;
100
+ /** A bearer credential, which is what most vendors want and what a plain string used to mean. */
101
+ export declare const bearer: (token: string, metadata?: CredentialMetadata, mode?: AuthMode) => Credential;
35
102
  export interface CredentialResolver {
36
103
  /**
37
- * The secret behind a reference, for this caller.
104
+ * The credential behind a reference, for this caller.
38
105
  *
39
106
  * Takes the context because a reference is resolved *per tenant*: two tenants using the same toolkit name the
40
107
  * same credential and mean different secrets, and a resolver that ignored the caller would hand one tenant
@@ -43,15 +110,207 @@ export interface CredentialResolver {
43
110
  resolve(input: {
44
111
  readonly ref: CredentialRef;
45
112
  readonly context: ExecutionContext;
46
- }): Promise<string>;
113
+ }): Promise<Credential>;
114
+ }
115
+ /**
116
+ * Told about every resolution — `docs/21`'s Connections section, "an audit record of every resolution".
117
+ *
118
+ * A sink rather than a return value, so a toolkit cannot forget to report and a host cannot be surprised by
119
+ * one. **Both outcomes are reported**: a refused resolution is the more interesting audit event, because a
120
+ * successful one is the normal case and a refused one is somebody asking for something they do not have.
121
+ *
122
+ * It never receives the credential. An audit trail that carries the secret is a second copy of the secret in a
123
+ * place designed to be kept for a long time.
124
+ */
125
+ export interface CredentialAudit {
126
+ onResolved(input: {
127
+ readonly ref: CredentialRef;
128
+ readonly context: ExecutionContext;
129
+ readonly scheme: CredentialScheme;
130
+ }): Promise<void> | void;
131
+ onRefused(input: {
132
+ readonly ref: CredentialRef;
133
+ readonly context: ExecutionContext;
134
+ readonly reason: string;
135
+ }): Promise<void> | void;
47
136
  }
48
137
  export declare const credentialMissing: (ref: CredentialRef, detail?: string) => PlatformError;
138
+ /**
139
+ * The credential resolved is not the shape this toolkit can present — #260 AC-2.
140
+ *
141
+ * Raised at **construction** where possible, and at resolution otherwise. A misconfiguration discovered as a
142
+ * vendor 401 an hour later is the failure this exists to prevent: the vendor's message says the token is
143
+ * invalid, which sends an operator to rotate a token that was never the problem.
144
+ */
145
+ export declare const credentialSchemeMismatch: (ref: CredentialRef, expected: readonly CredentialScheme[], got: CredentialScheme) => PlatformError;
49
146
  /**
50
147
  * A resolver over a plain map, for a single-tenant deployment and for tests.
51
148
  *
52
149
  * Shipped because the alternative is every host writing the same six lines, and the sixth one writing it with a
53
150
  * fallback to `process.env` — which is the thing this module exists to prevent. Explicitly *not* environment
54
151
  * backed: a host that wants that passes `{ github: process.env.GITHUB_TOKEN ?? "" }` and can see it doing so.
152
+ *
153
+ * **A bare string still works**, and stays the common case: it means a bearer token, which is what it meant
154
+ * before #260. The single-tenant path must not get harder because multi-tenant got possible.
155
+ */
156
+ export declare const createStaticCredentialResolver: (secrets: Readonly<Record<string, string | Credential>>) => CredentialResolver;
157
+ /**
158
+ * Wraps a resolver so every resolution is audited, and so a scheme mismatch is caught here rather than by the
159
+ * vendor — #260 AC-2 and AC-8.
160
+ *
161
+ * A wrapper rather than a change to every resolver: a host's own resolver stays a four-line object, and the
162
+ * cross-cutting obligations are added once, where they cannot be forgotten per toolkit.
163
+ */
164
+ export declare const withCredentialAudit: (resolver: CredentialResolver, audit: CredentialAudit, expected?: readonly CredentialScheme[]) => CredentialResolver;
165
+ /**
166
+ * The `Authorization`-style header a credential presents as.
167
+ *
168
+ * One function, so twenty toolkits do not each write their own base64 and get the padding wrong. Returns the
169
+ * header **name and value**, because `custom-header` does not use `Authorization`.
170
+ */
171
+ export declare const credentialHeader: (credential: Credential) => readonly [string, string];
172
+ /**
173
+ * What a toolkit accepts — #260 AC-2.
174
+ *
175
+ * Declared by the toolkit and checked at **construction**, so wiring a Basic-auth vendor with a bearer token is
176
+ * a startup error naming both, rather than a vendor 401 an hour later whose message says the token is invalid.
177
+ *
178
+ * `modes` is the other axis and is not the platform's business to validate — it is a fact about the vendor that
179
+ * a deployment reads: "GitHub takes a PAT or OAuth", "Google is OAuth only". #262 uses it to decide which
180
+ * connection flows to offer, and #264 uses it to decide whether an unconnected tool can pause a run for consent
181
+ * or must simply fail, since a token has no login URL.
182
+ */
183
+ export type ToolkitAuth = {
184
+ /** How a tenant may obtain the credential. */
185
+ readonly modes: readonly AuthMode[];
186
+ /** The scheme(s) this toolkit can present. A credential of any other scheme is refused. */
187
+ readonly schemes: readonly CredentialScheme[];
188
+ };
189
+ /**
190
+ * Refuses a toolkit configuration whose credential cannot be presented — at construction.
191
+ *
192
+ * Takes the *declared* scheme rather than resolving, because resolution needs a context and construction has
193
+ * none. A host that wires a static map can therefore be told immediately; a host whose resolver is dynamic is
194
+ * caught by `withCredentialAudit` at the first call instead. Both are before the vendor sees anything.
195
+ */
196
+ export declare const assertToolkitAuth: (ref: CredentialRef, auth: ToolkitAuth, declared: CredentialScheme | undefined) => void;
197
+ /**
198
+ * A credential that stops working at a known time.
199
+ *
200
+ * Additive by construction: `RefreshableCredential` is a `Credential` with one more field, so a resolver that
201
+ * returns a plain one is unchanged and the eight shipped toolkits compile untouched. That is AC-1, and it is a
202
+ * property of the *type* rather than a claim about the code.
203
+ *
204
+ * The refresh token is deliberately **not here.** It lives wherever the host keeps it — `ConnectionStore` seals
205
+ * it (#261) — and only the refresher ever sees it. Putting it on the credential would mean the longest-lived
206
+ * secret in an OAuth grant travelling through every toolkit that only needed the short-lived one.
207
+ */
208
+ export type RefreshableCredential = Credential & {
209
+ /** ISO 8601. When the vendor stops accepting this. */
210
+ readonly expiresAt: string;
211
+ };
212
+ /**
213
+ * Adds an expiry to a credential **without losing its secret protection** — AC-6.
214
+ *
215
+ * This exists because the obvious way to build one is wrong, and wrong invisibly. A host writing a
216
+ * `CredentialRefresher` reaches for:
217
+ *
218
+ * ```ts
219
+ * return { ...bearer(accessToken), expiresAt }; // ← the secret is now enumerable
220
+ * ```
221
+ *
222
+ * `createCredential` defines the secret **non-enumerably**, which is precisely what makes it survive a
223
+ * `JSON.stringify` into a log line — and precisely what a spread drops. The result looks identical, works
224
+ * identically, and serialises the token into the first structured log that touches it.
225
+ *
226
+ * Found by the AC-6 test failing against this repository's own test helper, which had made exactly that
227
+ * mistake. If the helper made it, a host will.
228
+ */
229
+ export declare const refreshable: (credential: Credential, expiresAt: string) => RefreshableCredential;
230
+ export declare const isRefreshable: (credential: Credential) => credential is RefreshableCredential;
231
+ /**
232
+ * How early a token is replaced.
233
+ *
234
+ * Sixty seconds, and the number is a *commitment* rather than a guess: a tool call can take tens of seconds —
235
+ * a slow vendor, a large upload, a retry — and a token that was valid when the call started must still be
236
+ * valid when it arrives. Refreshing exactly at expiry makes "expired mid-flight" the common case rather than
237
+ * the rare one, and that failure looks like an intermittent authentication bug.
238
+ *
239
+ * AC-5. Configurable because a deployment whose calls are slower than this needs more.
240
+ */
241
+ export declare const DEFAULT_REFRESH_SKEW_MS = 60000;
242
+ /** Whether a credential is expired, or close enough that it should be replaced before use. */
243
+ export declare const isExpiring: (credential: Credential, skewMs: number, now: number) => boolean;
244
+ /**
245
+ * Obtains a fresh credential for a reference.
246
+ *
247
+ * A port, because how a token is renewed differs entirely by vendor and by where the grant is stored. The
248
+ * host's implementation reads its own `ConnectionStore`, calls the vendor's token endpoint, re-seals the new
249
+ * refresh token and returns the new access credential — none of which the runtime should know about.
250
+ *
251
+ * It is given the ref and the context, and **not** the expired credential: it has to look the grant up anyway,
252
+ * and handing it a dead secret would be one more copy of one for no purpose.
253
+ */
254
+ export interface CredentialRefresher {
255
+ refresh(input: {
256
+ readonly ref: CredentialRef;
257
+ readonly context: ExecutionContext;
258
+ }): Promise<RefreshableCredential>;
259
+ }
260
+ /**
261
+ * A refresh that failed because the grant is gone, rather than because the network was.
262
+ *
263
+ * The distinction is the whole of AC-3, and it is not cosmetic: `invalid_grant` means a person must consent
264
+ * again and no amount of retrying will help, while a timeout means try again in a second. A runtime that
265
+ * conflates them either retries a dead grant forever or asks a user to re-authorise because of a blip.
266
+ */
267
+ export declare const REFRESH_GRANT_ERRORS: readonly ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied"];
268
+ export declare const isGrantError: (error: unknown) => boolean;
269
+ export type RefreshingResolverOptions = {
270
+ readonly skewMs?: number;
271
+ /** Injectable so a test can move time without waiting for it. */
272
+ readonly now?: () => number;
273
+ /**
274
+ * Told that a refresh happened. Never told the token — see `CredentialAudit`, same reasoning.
275
+ *
276
+ * `expiresAt` is included because it is not a secret and it is the one thing an operator debugging a
277
+ * refresh loop actually needs.
278
+ */
279
+ readonly onRefreshed?: (input: {
280
+ readonly ref: CredentialRef;
281
+ readonly tenantId: string;
282
+ readonly expiresAt: string;
283
+ }) => void;
284
+ };
285
+ /**
286
+ * Wraps a resolver so an expiring credential is renewed before it is handed out.
287
+ *
288
+ * A wrapper, like `withCredentialAudit`, and for the same reason: the eight shipped toolkits already resolve
289
+ * **per call**, so they pick this up without a line changing. A toolkit that cached a credential at
290
+ * construction would defeat it, which is why `createGitHubToolkit` and every sibling resolve inside `call()`.
291
+ *
292
+ * ## Time-driven, never 401-driven — AC-7
293
+ *
294
+ * The obvious design is to refresh when the vendor returns 401. It is wrong, and worth stating plainly because
295
+ * it is what most integrations do:
296
+ *
297
+ * A 401 is what a vendor returns for an expired token, a **revoked** grant, a token for the wrong tenant, and a
298
+ * scope the grant never had. Refreshing on 401 therefore turns a revoked grant into an infinite refresh loop
299
+ * against the vendor's token endpoint, and turns a missing scope into a refresh that succeeds and a call that
300
+ * fails again identically. Neither is diagnosable from the outside.
301
+ *
302
+ * Time is the only signal that means what it says: a token with an expiry in the past is expired, and nothing
303
+ * else is inferred from it. A 401 on a freshly-refreshed token is a real error and is surfaced as one.
304
+ *
305
+ * ## One refresh, not N — AC-2
306
+ *
307
+ * Twenty concurrent tool calls hitting an expired token must produce **one** refresh. Refresh endpoints rate
308
+ * limit, and — worse — several vendors invalidate the previous refresh token when one is used, so N concurrent
309
+ * refreshes race to invalidate each other and log the deployment out permanently.
310
+ *
311
+ * The in-flight promise is stored *before* the first await, so a second caller entering the function
312
+ * synchronously after the first still finds it.
55
313
  */
56
- export declare const createStaticCredentialResolver: (secrets: Readonly<Record<string, string>>) => CredentialResolver;
314
+ export declare const withRefreshingCredentials: (resolver: CredentialResolver, refresher: CredentialRefresher, options?: RefreshingResolverOptions) => CredentialResolver;
315
+ export {};
57
316
  //# sourceMappingURL=credentials.d.ts.map
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5.
2
+ * Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5; widened by REQ-063 (#259), #260.
3
3
  *
4
- * A toolkit needs a token. The question is who holds it and when it is read, and getting that wrong is the
4
+ * A toolkit needs a secret. The question is who holds it and when it is read, and getting that wrong is the
5
5
  * decision that makes a multi-tenant deployment a rewrite of every package rather than a configuration change.
6
6
  *
7
7
  * ## Why a tool must not read the environment
@@ -27,7 +27,74 @@
27
27
  *
28
28
  * Returning an empty string would send an unauthenticated request and surface as a vendor 401 several layers
29
29
  * away, where the actual problem — nobody wired the credential — is invisible. A typed failure names the ref.
30
+ *
31
+ * ## Why a string was not enough — #260
32
+ *
33
+ * `resolve()` returned `Promise<string>`, which covers a bearer token and nothing else. Jira and Confluence
34
+ * want an account email *and* an API token as Basic auth; Atlassian's OAuth needs an access token *and* a cloud
35
+ * id discovered after consent; WhatsApp needs a token *and* a phone number id; a vendor using `X-Api-Key` needs
36
+ * a header name. Four of the fourteen integrations specified in `docs/23` cannot be expressed as a string, and
37
+ * every one of them would have grown its own side-channel.
38
+ *
39
+ * Two axes, deliberately kept apart, because they are answered by different people:
40
+ *
41
+ * - **`CredentialScheme`** — how the secret is presented on the wire. The toolkit knows this.
42
+ * - **`AuthMode`** — how the tenant *obtained* it: pasted a token, or completed an OAuth flow. The deployment
43
+ * knows this, and it is what decides whether an unconnected tool can pause a run for consent (#264) or must
44
+ * simply fail, since a token has no login URL to redirect to.
45
+ */
46
+ import { AgentPlatformError } from "../core/errors.js";
47
+ /** How a secret is presented on the wire. */
48
+ export const CREDENTIAL_SCHEMES = ["bearer", "basic", "custom-header"];
49
+ /**
50
+ * How a tenant supplied the credential.
51
+ *
52
+ * Not the same question as the scheme: an OAuth access token is presented as a bearer, so the two would collapse
53
+ * if they shared a type — and the collapse would lose exactly the fact #264 needs, which is whether there is a
54
+ * login URL to send someone to.
30
55
  */
56
+ export const AUTH_MODES = ["token", "oauth2"];
57
+ /** The secret-bearing property of each scheme — the ones that must never be enumerable. */
58
+ const SECRET_KEYS = {
59
+ bearer: ["token"],
60
+ basic: ["password"],
61
+ "custom-header": ["value"],
62
+ };
63
+ const REDACTED = "[credential redacted]";
64
+ /**
65
+ * Builds a credential whose secret is **not enumerable** — AC-7.
66
+ *
67
+ * A typed object is far more likely to reach a log line than a bare string was: it gets spread into an error's
68
+ * `details`, passed to a structured logger, or serialised into an audit row, and every one of those uses
69
+ * `JSON.stringify` or an object spread. So the secret is defined non-enumerably and `toJSON`/`toString`/
70
+ * `util.inspect` are overridden. `credential.token` still reads normally; `{ ...credential }`,
71
+ * `JSON.stringify(credential)` and `console.log(credential)` do not.
72
+ *
73
+ * This is defence in depth, not a licence: a caller that reads `.token` and logs *that* is still logging a
74
+ * secret, and no type can stop it.
75
+ */
76
+ export const createCredential = (input) => {
77
+ const secrets = SECRET_KEYS[input.scheme];
78
+ const credential = { ...input };
79
+ for (const key of secrets) {
80
+ const value = credential[key];
81
+ delete credential[key];
82
+ Object.defineProperty(credential, key, { value, enumerable: false, writable: false, configurable: false });
83
+ }
84
+ Object.defineProperty(credential, "toJSON", {
85
+ value: () => ({ scheme: input.scheme, ...(input.mode === undefined ? {} : { mode: input.mode }), secret: REDACTED }),
86
+ enumerable: false,
87
+ });
88
+ Object.defineProperty(credential, "toString", { value: () => REDACTED, enumerable: false });
89
+ // Node's `console.log` and `util.inspect` ignore `toString`; this is the hook they do read.
90
+ Object.defineProperty(credential, Symbol.for("nodejs.util.inspect.custom"), {
91
+ value: () => `Credential(${input.scheme}) ${REDACTED}`,
92
+ enumerable: false,
93
+ });
94
+ return Object.freeze(credential);
95
+ };
96
+ /** A bearer credential, which is what most vendors want and what a plain string used to mean. */
97
+ export const bearer = (token, metadata, mode) => createCredential({ scheme: "bearer", token, ...(metadata === undefined ? {} : { metadata }), ...(mode === undefined ? {} : { mode }) });
31
98
  export const credentialMissing = (ref, detail) => ({
32
99
  code: "capability_unavailable",
33
100
  message: `No credential is wired for reference "${ref}"` +
@@ -36,19 +103,267 @@ export const credentialMissing = (ref, detail) => ({
36
103
  // Retrying an unwired credential cannot help; something has to be configured.
37
104
  retryable: false,
38
105
  });
106
+ /**
107
+ * The credential resolved is not the shape this toolkit can present — #260 AC-2.
108
+ *
109
+ * Raised at **construction** where possible, and at resolution otherwise. A misconfiguration discovered as a
110
+ * vendor 401 an hour later is the failure this exists to prevent: the vendor's message says the token is
111
+ * invalid, which sends an operator to rotate a token that was never the problem.
112
+ */
113
+ export const credentialSchemeMismatch = (ref, expected, got) => ({
114
+ code: "capability_unavailable",
115
+ message: `Credential "${ref}" is a ${got} credential and this toolkit presents ${expected.join(" or ")}. ` +
116
+ "The vendor would answer 401 and the message would say the token is invalid, which is not the problem.",
117
+ retryable: false,
118
+ });
39
119
  /**
40
120
  * A resolver over a plain map, for a single-tenant deployment and for tests.
41
121
  *
42
122
  * Shipped because the alternative is every host writing the same six lines, and the sixth one writing it with a
43
123
  * fallback to `process.env` — which is the thing this module exists to prevent. Explicitly *not* environment
44
124
  * backed: a host that wants that passes `{ github: process.env.GITHUB_TOKEN ?? "" }` and can see it doing so.
125
+ *
126
+ * **A bare string still works**, and stays the common case: it means a bearer token, which is what it meant
127
+ * before #260. The single-tenant path must not get harder because multi-tenant got possible.
45
128
  */
46
129
  export const createStaticCredentialResolver = (secrets) => ({
47
130
  async resolve({ ref }) {
48
131
  const secret = secrets[ref];
49
132
  if (secret === undefined || secret === "")
50
133
  throw credentialMissing(ref, "not present in the static map");
51
- return secret;
134
+ return typeof secret === "string" ? bearer(secret) : secret;
52
135
  },
53
136
  });
137
+ /**
138
+ * Wraps a resolver so every resolution is audited, and so a scheme mismatch is caught here rather than by the
139
+ * vendor — #260 AC-2 and AC-8.
140
+ *
141
+ * A wrapper rather than a change to every resolver: a host's own resolver stays a four-line object, and the
142
+ * cross-cutting obligations are added once, where they cannot be forgotten per toolkit.
143
+ */
144
+ export const withCredentialAudit = (resolver, audit, expected) => ({
145
+ async resolve(input) {
146
+ let credential;
147
+ try {
148
+ credential = await resolver.resolve(input);
149
+ }
150
+ catch (thrown) {
151
+ const reason = thrown instanceof Error ? thrown.message : String(thrown);
152
+ await audit.onRefused({ ref: input.ref, context: input.context, reason });
153
+ throw thrown;
154
+ }
155
+ if (expected !== undefined && !expected.includes(credential.scheme)) {
156
+ const error = credentialSchemeMismatch(input.ref, expected, credential.scheme);
157
+ await audit.onRefused({ ref: input.ref, context: input.context, reason: error.message });
158
+ throw error;
159
+ }
160
+ await audit.onResolved({ ref: input.ref, context: input.context, scheme: credential.scheme });
161
+ return credential;
162
+ },
163
+ });
164
+ /**
165
+ * The `Authorization`-style header a credential presents as.
166
+ *
167
+ * One function, so twenty toolkits do not each write their own base64 and get the padding wrong. Returns the
168
+ * header **name and value**, because `custom-header` does not use `Authorization`.
169
+ */
170
+ export const credentialHeader = (credential) => {
171
+ switch (credential.scheme) {
172
+ case "bearer":
173
+ return ["Authorization", `Bearer ${credential.token}`];
174
+ case "basic":
175
+ return ["Authorization", `Basic ${Buffer.from(`${credential.username}:${credential.password}`).toString("base64")}`];
176
+ case "custom-header":
177
+ return [credential.header, credential.value];
178
+ }
179
+ };
180
+ /**
181
+ * Refuses a toolkit configuration whose credential cannot be presented — at construction.
182
+ *
183
+ * Takes the *declared* scheme rather than resolving, because resolution needs a context and construction has
184
+ * none. A host that wires a static map can therefore be told immediately; a host whose resolver is dynamic is
185
+ * caught by `withCredentialAudit` at the first call instead. Both are before the vendor sees anything.
186
+ */
187
+ export const assertToolkitAuth = (ref, auth, declared) => {
188
+ if (auth.schemes.length === 0)
189
+ throw new Error(`toolkit auth for "${ref}" declares no schemes, so no credential could ever satisfy it`);
190
+ if (declared !== undefined && !auth.schemes.includes(declared)) {
191
+ const error = credentialSchemeMismatch(ref, auth.schemes, declared);
192
+ throw Object.assign(new Error(error.message), error);
193
+ }
194
+ };
195
+ /**
196
+ * Adds an expiry to a credential **without losing its secret protection** — AC-6.
197
+ *
198
+ * This exists because the obvious way to build one is wrong, and wrong invisibly. A host writing a
199
+ * `CredentialRefresher` reaches for:
200
+ *
201
+ * ```ts
202
+ * return { ...bearer(accessToken), expiresAt }; // ← the secret is now enumerable
203
+ * ```
204
+ *
205
+ * `createCredential` defines the secret **non-enumerably**, which is precisely what makes it survive a
206
+ * `JSON.stringify` into a log line — and precisely what a spread drops. The result looks identical, works
207
+ * identically, and serialises the token into the first structured log that touches it.
208
+ *
209
+ * Found by the AC-6 test failing against this repository's own test helper, which had made exactly that
210
+ * mistake. If the helper made it, a host will.
211
+ */
212
+ export const refreshable = (credential, expiresAt) => {
213
+ const secrets = SECRET_KEYS[credential.scheme];
214
+ /**
215
+ * Rebuilt **through** `createCredential`, with the expiry passed in rather than added after.
216
+ *
217
+ * Two reasons it has to be this way round. The protection must be *applied* rather than copied — a copy of a
218
+ * non-enumerable property is an enumerable one — and `createCredential` freezes what it returns, so there is
219
+ * no "after" to add a field in. The secrets are read back explicitly because the spread above cannot see
220
+ * them, which is the whole point of them.
221
+ */
222
+ return createCredential({
223
+ ...credential,
224
+ ...Object.fromEntries(secrets.map((key) => [key, credential[key]])),
225
+ expiresAt,
226
+ });
227
+ };
228
+ export const isRefreshable = (credential) => typeof credential.expiresAt === "string";
229
+ /**
230
+ * How early a token is replaced.
231
+ *
232
+ * Sixty seconds, and the number is a *commitment* rather than a guess: a tool call can take tens of seconds —
233
+ * a slow vendor, a large upload, a retry — and a token that was valid when the call started must still be
234
+ * valid when it arrives. Refreshing exactly at expiry makes "expired mid-flight" the common case rather than
235
+ * the rare one, and that failure looks like an intermittent authentication bug.
236
+ *
237
+ * AC-5. Configurable because a deployment whose calls are slower than this needs more.
238
+ */
239
+ export const DEFAULT_REFRESH_SKEW_MS = 60_000;
240
+ /** Whether a credential is expired, or close enough that it should be replaced before use. */
241
+ export const isExpiring = (credential, skewMs, now) => {
242
+ if (!isRefreshable(credential))
243
+ return false;
244
+ const expiresAt = Date.parse(credential.expiresAt);
245
+ // An unparseable expiry is treated as expiring. The alternative is using a credential whose lifetime is
246
+ // unknown, and the cost of an unnecessary refresh is one call.
247
+ return Number.isNaN(expiresAt) || expiresAt - now <= skewMs;
248
+ };
249
+ /**
250
+ * A refresh that failed because the grant is gone, rather than because the network was.
251
+ *
252
+ * The distinction is the whole of AC-3, and it is not cosmetic: `invalid_grant` means a person must consent
253
+ * again and no amount of retrying will help, while a timeout means try again in a second. A runtime that
254
+ * conflates them either retries a dead grant forever or asks a user to re-authorise because of a blip.
255
+ */
256
+ export const REFRESH_GRANT_ERRORS = ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied"];
257
+ export const isGrantError = (error) => {
258
+ const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
259
+ return REFRESH_GRANT_ERRORS.some((code) => text.includes(code));
260
+ };
261
+ /**
262
+ * Wraps a resolver so an expiring credential is renewed before it is handed out.
263
+ *
264
+ * A wrapper, like `withCredentialAudit`, and for the same reason: the eight shipped toolkits already resolve
265
+ * **per call**, so they pick this up without a line changing. A toolkit that cached a credential at
266
+ * construction would defeat it, which is why `createGitHubToolkit` and every sibling resolve inside `call()`.
267
+ *
268
+ * ## Time-driven, never 401-driven — AC-7
269
+ *
270
+ * The obvious design is to refresh when the vendor returns 401. It is wrong, and worth stating plainly because
271
+ * it is what most integrations do:
272
+ *
273
+ * A 401 is what a vendor returns for an expired token, a **revoked** grant, a token for the wrong tenant, and a
274
+ * scope the grant never had. Refreshing on 401 therefore turns a revoked grant into an infinite refresh loop
275
+ * against the vendor's token endpoint, and turns a missing scope into a refresh that succeeds and a call that
276
+ * fails again identically. Neither is diagnosable from the outside.
277
+ *
278
+ * Time is the only signal that means what it says: a token with an expiry in the past is expired, and nothing
279
+ * else is inferred from it. A 401 on a freshly-refreshed token is a real error and is surfaced as one.
280
+ *
281
+ * ## One refresh, not N — AC-2
282
+ *
283
+ * Twenty concurrent tool calls hitting an expired token must produce **one** refresh. Refresh endpoints rate
284
+ * limit, and — worse — several vendors invalidate the previous refresh token when one is used, so N concurrent
285
+ * refreshes race to invalidate each other and log the deployment out permanently.
286
+ *
287
+ * The in-flight promise is stored *before* the first await, so a second caller entering the function
288
+ * synchronously after the first still finds it.
289
+ */
290
+ export const withRefreshingCredentials = (resolver, refresher, options = {}) => {
291
+ const skewMs = Math.max(0, options.skewMs ?? DEFAULT_REFRESH_SKEW_MS);
292
+ const now = options.now ?? (() => Date.now());
293
+ /**
294
+ * Keyed by tenant **and** ref — AC-4.
295
+ *
296
+ * Two tenants naming the same credential `"google"` mean different grants, and a cache keyed by ref alone
297
+ * would hand one tenant the other's token after a refresh. A space separates them: a tenant id cannot
298
+ * contain one, so `a` + `b c` and `a b` + `c` cannot collide.
299
+ */
300
+ const cached = new Map();
301
+ const inFlight = new Map();
302
+ const keyOf = (tenantId, ref) => `${tenantId} ${ref}`;
303
+ const refreshOnce = (key, input) => {
304
+ const existing = inFlight.get(key);
305
+ if (existing !== undefined)
306
+ return existing;
307
+ // Built and stored before the first await, so a synchronous second caller joins this one rather than
308
+ // starting a second refresh.
309
+ const attempt = (async () => {
310
+ try {
311
+ const fresh = await refresher.refresh({ ref: input.ref, context: input.context });
312
+ cached.set(key, fresh);
313
+ options.onRefreshed?.({
314
+ ref: input.ref,
315
+ tenantId: String(input.context.tenantId),
316
+ expiresAt: fresh.expiresAt,
317
+ });
318
+ return fresh;
319
+ }
320
+ catch (thrown) {
321
+ /**
322
+ * Belt-and-braces, and **not** load-bearing — worth saying rather than implying otherwise.
323
+ *
324
+ * A cached credential that is expiring already fails the freshness guard in `resolve`, so the next
325
+ * caller would attempt a refresh whether or not this line ran. Removing it breaks no test, which is
326
+ * exactly what one should expect. It stays because the invariant it states — a credential known to be
327
+ * dead is never held — is one a future edit could otherwise quietly rely on being false.
328
+ */
329
+ cached.delete(key);
330
+ throw new AgentPlatformError(isGrantError(thrown)
331
+ ? {
332
+ code: "unauthorized",
333
+ message: `The stored authorisation for "${input.ref}" is no longer valid and could not be renewed. ` +
334
+ "Someone needs to connect the account again — retrying will not help.",
335
+ retryable: false,
336
+ }
337
+ : {
338
+ code: "provider_unavailable",
339
+ message: `Could not renew the authorisation for "${input.ref}" right now.`,
340
+ retryable: true,
341
+ }, { cause: thrown });
342
+ }
343
+ finally {
344
+ inFlight.delete(key);
345
+ }
346
+ })();
347
+ inFlight.set(key, attempt);
348
+ return attempt;
349
+ };
350
+ return {
351
+ async resolve(input) {
352
+ const key = keyOf(String(input.context.tenantId), input.ref);
353
+ const held = cached.get(key);
354
+ if (held !== undefined && !isExpiring(held, skewMs, now()))
355
+ return held;
356
+ // Ask the underlying resolver first: it is the source of truth, and on the common path the credential it
357
+ // returns is a plain one with no expiry, which is passed straight through.
358
+ const resolved = await resolver.resolve(input);
359
+ if (!isRefreshable(resolved))
360
+ return resolved;
361
+ if (!isExpiring(resolved, skewMs, now())) {
362
+ cached.set(key, resolved);
363
+ return resolved;
364
+ }
365
+ return refreshOnce(key, input);
366
+ },
367
+ };
368
+ };
54
369
  //# sourceMappingURL=credentials.js.map