@absol-labs/agent 0.7.2 → 0.8.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 (41) hide show
  1. package/README.md +19 -4
  2. package/dist/gateway/caller-auth-gateway.d.ts +103 -2
  3. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  4. package/dist/gateway/caller-auth-gateway.js +176 -19
  5. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  6. package/dist/gateway/http-server.d.ts +12 -0
  7. package/dist/gateway/http-server.d.ts.map +1 -1
  8. package/dist/gateway/http-server.js +45 -1
  9. package/dist/gateway/http-server.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/wallet/autonomous-wallet-store.d.ts +123 -0
  15. package/dist/wallet/autonomous-wallet-store.d.ts.map +1 -0
  16. package/dist/wallet/autonomous-wallet-store.js +318 -0
  17. package/dist/wallet/autonomous-wallet-store.js.map +1 -0
  18. package/dist/wallet/autonomous-wallet.d.ts +14 -39
  19. package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
  20. package/dist/wallet/autonomous-wallet.js +12 -145
  21. package/dist/wallet/autonomous-wallet.js.map +1 -1
  22. package/dist/wallet/encrypted-file-credential-store.d.ts +41 -0
  23. package/dist/wallet/encrypted-file-credential-store.d.ts.map +1 -0
  24. package/dist/wallet/encrypted-file-credential-store.js +221 -0
  25. package/dist/wallet/encrypted-file-credential-store.js.map +1 -0
  26. package/dist/wallet/provider.d.ts.map +1 -1
  27. package/dist/wallet/provider.js +22 -2
  28. package/dist/wallet/provider.js.map +1 -1
  29. package/dist/wallet/secret-service-probe.d.ts +56 -0
  30. package/dist/wallet/secret-service-probe.d.ts.map +1 -0
  31. package/dist/wallet/secret-service-probe.js +407 -0
  32. package/dist/wallet/secret-service-probe.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/gateway/caller-auth-gateway.ts +281 -15
  35. package/src/gateway/http-server.ts +64 -0
  36. package/src/index.ts +19 -0
  37. package/src/wallet/autonomous-wallet-store.ts +487 -0
  38. package/src/wallet/autonomous-wallet.ts +57 -224
  39. package/src/wallet/encrypted-file-credential-store.ts +341 -0
  40. package/src/wallet/provider.ts +23 -1
  41. package/src/wallet/secret-service-probe.ts +487 -0
@@ -0,0 +1,487 @@
1
+ import { createPrivateKey, createPublicKey } from "node:crypto";
2
+
3
+ import { getAddress, type Address } from "viem";
4
+
5
+ import { AUTONOMOUS_WALLET_VERSION } from "./autonomous-wallet-protocol.js";
6
+ import {
7
+ probeSecretServiceAvailable,
8
+ type SecretServiceProbeResult,
9
+ } from "./secret-service-probe.js";
10
+
11
+ export const AUTONOMOUS_WALLET_KEYCHAIN_SERVICE =
12
+ "com.absol-labs.metrik.autonomous-wallet";
13
+
14
+ export interface AutonomousWalletRecord {
15
+ readonly version: typeof AUTONOMOUS_WALLET_VERSION;
16
+ readonly appId: string;
17
+ readonly brokerUrl: string;
18
+ readonly publicKey: string;
19
+ /** Base64 PKCS8 P-256 key. This field must only be stored in a secure store. */
20
+ readonly privateKey: string;
21
+ readonly provisioningNonce: string;
22
+ readonly walletId?: string;
23
+ readonly address?: Address;
24
+ readonly policyId?: string;
25
+ readonly createdAt: number;
26
+ }
27
+
28
+ /**
29
+ * Whether a credential store keeps a key across a host reboot.
30
+ *
31
+ * - `durable`: the key survives a reboot (macOS Keychain, Windows Credential
32
+ * Manager, a freedesktop Secret Service provider, an encrypted file).
33
+ * - `volatile`: the key is lost on reboot (process memory, the Linux kernel
34
+ * keyring). Provisioning a fund-controlling wallet onto one of these is
35
+ * refused unless the caller explicitly opts in.
36
+ * - `unknown`: an injected third-party store that does not implement
37
+ * `probeDurability()`. Allowed for backwards compatibility, with a warning.
38
+ */
39
+ export type CredentialStoreDurability = "durable" | "volatile" | "unknown";
40
+
41
+ export interface CredentialStoreDurabilityReport {
42
+ readonly durability: CredentialStoreDurability;
43
+ /** Stable identifier of the resolved backend, e.g. `linux-kernel-keyring`. */
44
+ readonly backend: string;
45
+ readonly detail: string;
46
+ }
47
+
48
+ export interface AutonomousWalletCredentialStore {
49
+ load(key: string): Promise<AutonomousWalletRecord | null>;
50
+ save(key: string, record: AutonomousWalletRecord): Promise<void>;
51
+ /**
52
+ * Optional capability probe. Implementations SHOULD round-trip a throwaway
53
+ * probe credential and report whether the resolved backend survives a
54
+ * reboot. Stores that omit it are treated as `unknown`.
55
+ */
56
+ probeDurability?(): Promise<CredentialStoreDurabilityReport>;
57
+ }
58
+
59
+ export class AutonomousWalletError extends Error {
60
+ constructor(
61
+ readonly code:
62
+ | "invalid-config"
63
+ | "store-failure"
64
+ | "broker-failure"
65
+ | "proof-failure"
66
+ | "invalid-response",
67
+ message: string,
68
+ options?: { readonly cause?: unknown },
69
+ ) {
70
+ super(message, options);
71
+ this.name = "AutonomousWalletError";
72
+ }
73
+ }
74
+
75
+ export class InMemoryAutonomousWalletStore implements AutonomousWalletCredentialStore {
76
+ private readonly records = new Map<string, AutonomousWalletRecord>();
77
+
78
+ async load(key: string): Promise<AutonomousWalletRecord | null> {
79
+ return this.records.get(key) ?? null;
80
+ }
81
+
82
+ async save(key: string, record: AutonomousWalletRecord): Promise<void> {
83
+ this.records.set(key, record);
84
+ }
85
+
86
+ async probeDurability(): Promise<CredentialStoreDurabilityReport> {
87
+ return {
88
+ durability: "volatile",
89
+ backend: "in-memory",
90
+ detail:
91
+ "records live in process memory only and are lost when the process exits",
92
+ };
93
+ }
94
+ }
95
+
96
+ /** The subset of `@napi-rs/keyring`'s `AsyncEntry` this package relies on. */
97
+ export interface SecretEntryLike {
98
+ setPassword(password: string): Promise<void>;
99
+ getPassword(): Promise<string | undefined | null>;
100
+ deleteCredential?(): Promise<boolean>;
101
+ }
102
+
103
+ export type SecretEntryFactory = (
104
+ service: string,
105
+ key: string,
106
+ ) => Promise<SecretEntryLike>;
107
+
108
+ export interface OsCredentialStoreOptions {
109
+ readonly service?: string;
110
+ /** Override for tests; defaults to `process.platform`. */
111
+ readonly platform?: string;
112
+ /** Override for tests; defaults to the real D-Bus Secret Service probe. */
113
+ readonly secretServiceProbe?: () => Promise<SecretServiceProbeResult>;
114
+ /** Override for tests; defaults to `@napi-rs/keyring`'s `AsyncEntry`. */
115
+ readonly entryFactory?: SecretEntryFactory;
116
+ }
117
+
118
+ const DURABILITY_PROBE_KEY_PREFIX = "__metrik-durability-probe__";
119
+
120
+ /**
121
+ * Uses the host OS credential manager. There is intentionally no plaintext
122
+ * file fallback: unsupported hosts receive an actionable error instead.
123
+ *
124
+ * IMPORTANT: on Linux the underlying native module silently falls back from the
125
+ * freedesktop Secret Service to the Linux kernel keyring, which is destroyed on
126
+ * reboot. `probeDurability()` detects that and reports `volatile`, which makes
127
+ * `provisionAutonomousWallet` refuse rather than lose the key later.
128
+ */
129
+ export class OsCredentialStore implements AutonomousWalletCredentialStore {
130
+ private readonly service: string;
131
+ private readonly platform: string;
132
+ private readonly secretServiceProbe: () => Promise<SecretServiceProbeResult>;
133
+ private readonly entryFactory: SecretEntryFactory;
134
+
135
+ constructor(options: OsCredentialStoreOptions = {}) {
136
+ this.service = options.service ?? AUTONOMOUS_WALLET_KEYCHAIN_SERVICE;
137
+ this.platform = options.platform ?? process.platform;
138
+ this.secretServiceProbe =
139
+ options.secretServiceProbe ?? (() => probeSecretServiceAvailable());
140
+ this.entryFactory =
141
+ options.entryFactory ??
142
+ (async (service, key) => {
143
+ const { AsyncEntry } = await import("@napi-rs/keyring");
144
+ return new AsyncEntry(service, key) as unknown as SecretEntryLike;
145
+ });
146
+ }
147
+
148
+ async load(key: string): Promise<AutonomousWalletRecord | null> {
149
+ const raw = await this.readSecret(key);
150
+ if (raw === null) return null;
151
+ try {
152
+ return validateStoredRecord(JSON.parse(raw));
153
+ } catch (error) {
154
+ if (error instanceof AutonomousWalletError) throw error;
155
+ throw new AutonomousWalletError(
156
+ "store-failure",
157
+ "OS credential contains invalid wallet data",
158
+ { cause: error },
159
+ );
160
+ }
161
+ }
162
+
163
+ async save(key: string, record: AutonomousWalletRecord): Promise<void> {
164
+ const raw = JSON.stringify(record);
165
+ try {
166
+ const entry = await this.entryFactory(this.service, key);
167
+ await entry.setPassword(raw);
168
+ return;
169
+ } catch (error) {
170
+ throw new AutonomousWalletError(
171
+ "store-failure",
172
+ "OS credential store rejected the autonomous wallet record; no plaintext fallback is allowed",
173
+ { cause: error },
174
+ );
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Startup self-check. Round-trips a throwaway probe credential (so a broken
180
+ * or locked keychain is caught before a key is generated) and resolves which
181
+ * backend the host actually provides.
182
+ */
183
+ async probeDurability(): Promise<CredentialStoreDurabilityReport> {
184
+ const platformReport = await this.resolvePlatformDurability();
185
+ const roundTrip = await this.roundTripProbe();
186
+ if (roundTrip !== null) {
187
+ return {
188
+ durability: "volatile",
189
+ backend: platformReport.backend,
190
+ detail: `credential round-trip self-check failed: ${roundTrip}`,
191
+ };
192
+ }
193
+ return platformReport;
194
+ }
195
+
196
+ private async resolvePlatformDurability(): Promise<CredentialStoreDurabilityReport> {
197
+ if (this.platform === "darwin") {
198
+ return {
199
+ durability: "durable",
200
+ backend: "macos-keychain",
201
+ detail: "the macOS Keychain persists credentials across reboots",
202
+ };
203
+ }
204
+ if (this.platform === "win32") {
205
+ return {
206
+ durability: "durable",
207
+ backend: "windows-credential-manager",
208
+ detail:
209
+ "the Windows Credential Manager persists credentials across reboots",
210
+ };
211
+ }
212
+ if (this.platform === "linux") {
213
+ let probe: SecretServiceProbeResult;
214
+ try {
215
+ probe = await this.secretServiceProbe();
216
+ } catch (error) {
217
+ probe = {
218
+ available: false,
219
+ detail: `Secret Service probe failed: ${error instanceof Error ? error.message : String(error)}`,
220
+ };
221
+ }
222
+ if (probe.available) {
223
+ return {
224
+ durability: "durable",
225
+ backend: "linux-secret-service",
226
+ detail: probe.detail,
227
+ };
228
+ }
229
+ return {
230
+ durability: "volatile",
231
+ backend: "linux-kernel-keyring",
232
+ detail: `${probe.detail}; the keyring falls back to the Linux kernel keyring, which is in-memory and destroyed on reboot`,
233
+ };
234
+ }
235
+ return {
236
+ durability: "unknown",
237
+ backend: `os-credential-store:${this.platform}`,
238
+ detail: `durability of the OS credential store on "${this.platform}" could not be determined`,
239
+ };
240
+ }
241
+
242
+ /** Returns `null` on success, or a description of the failure. */
243
+ private async roundTripProbe(): Promise<string | null> {
244
+ const key = `${DURABILITY_PROBE_KEY_PREFIX}${Date.now().toString(36)}`;
245
+ const expected = `metrik-durability-probe:${Math.random().toString(36).slice(2)}`;
246
+ let entry: SecretEntryLike;
247
+ try {
248
+ entry = await this.entryFactory(this.service, key);
249
+ } catch (error) {
250
+ return error instanceof Error ? error.message : String(error);
251
+ }
252
+ try {
253
+ await entry.setPassword(expected);
254
+ const observed = await entry.getPassword();
255
+ if (observed !== expected) {
256
+ return "the probe credential did not read back with the value that was written";
257
+ }
258
+ return null;
259
+ } catch (error) {
260
+ return error instanceof Error ? error.message : String(error);
261
+ } finally {
262
+ try {
263
+ await entry.deleteCredential?.();
264
+ } catch {
265
+ // A leftover probe credential is harmless; never fail the probe on it.
266
+ }
267
+ }
268
+ }
269
+
270
+ private async readSecret(key: string): Promise<string | null> {
271
+ try {
272
+ const entry = await this.entryFactory(this.service, key);
273
+ return (await entry.getPassword()) ?? null;
274
+ } catch (error) {
275
+ throw new AutonomousWalletError(
276
+ "store-failure",
277
+ "OS credential store could not read the autonomous wallet record",
278
+ { cause: error },
279
+ );
280
+ }
281
+ }
282
+ }
283
+
284
+ export function createDefaultAutonomousWalletStore(): AutonomousWalletCredentialStore {
285
+ return new OsCredentialStore();
286
+ }
287
+
288
+ export interface AssertDurableCredentialStoreOptions {
289
+ /**
290
+ * Opt in to a volatile credential store. Only for genuinely ephemeral flows
291
+ * (CI, tests, a throwaway container) where losing the key is acceptable.
292
+ * A wallet provisioned this way cannot be recovered after a reboot.
293
+ */
294
+ readonly allowVolatileStore?: boolean;
295
+ readonly logger?: { warn(...args: readonly unknown[]): void };
296
+ }
297
+
298
+ function volatileStoreMessage(report: CredentialStoreDurabilityReport): string {
299
+ return [
300
+ `autonomous wallet provisioning refused: the resolved credential store (${report.backend}) is NOT durable — ${report.detail}.`,
301
+ "The P-256 owner key would be lost on reboot, permanently losing control of any funds held by the wallet.",
302
+ "Remedies:",
303
+ "(1) use EncryptedFileCredentialStore with METRIK_WALLET_ENCRYPTION_KEY supplied from your secret manager — durable on any headless host;",
304
+ "(2) on Linux, install a freedesktop Secret Service provider (for example gnome-keyring plus dbus-user-session) and run inside a D-Bus session;",
305
+ "(3) inject your own durable AutonomousWalletCredentialStore;",
306
+ "(4) for genuinely ephemeral runs only, pass allowVolatileStore: true and accept that the wallet is unrecoverable.",
307
+ ].join(" ");
308
+ }
309
+
310
+ /**
311
+ * Fail-closed durability gate. Run before any wallet key is generated or any
312
+ * stored wallet is returned.
313
+ */
314
+ export async function assertDurableCredentialStore(
315
+ store: AutonomousWalletCredentialStore,
316
+ options: AssertDurableCredentialStoreOptions = {},
317
+ ): Promise<CredentialStoreDurabilityReport> {
318
+ const logger = options.logger ?? console;
319
+ if (typeof store.probeDurability !== "function") {
320
+ const report: CredentialStoreDurabilityReport = {
321
+ durability: "unknown",
322
+ backend: "custom",
323
+ detail:
324
+ "the injected credential store does not implement probeDurability(), so durability could not be verified",
325
+ };
326
+ logger.warn(
327
+ `[metrik] autonomous wallet: ${report.detail}. Confirm it survives a host reboot before funding this wallet.`,
328
+ );
329
+ return report;
330
+ }
331
+
332
+ let report: CredentialStoreDurabilityReport;
333
+ try {
334
+ report = await store.probeDurability();
335
+ } catch (error) {
336
+ throw new AutonomousWalletError(
337
+ "store-failure",
338
+ "credential store durability self-check failed; refusing to provision an autonomous wallet",
339
+ { cause: error },
340
+ );
341
+ }
342
+
343
+ if (report.durability === "durable") return report;
344
+
345
+ if (report.durability === "unknown") {
346
+ logger.warn(
347
+ `[metrik] autonomous wallet: credential store durability is unverified (${report.backend}) — ${report.detail}. Confirm it survives a host reboot before funding this wallet.`,
348
+ );
349
+ return report;
350
+ }
351
+
352
+ if (options.allowVolatileStore !== true) {
353
+ throw new AutonomousWalletError(
354
+ "store-failure",
355
+ volatileStoreMessage(report),
356
+ );
357
+ }
358
+
359
+ logger.warn(
360
+ `[metrik] WARNING: autonomous wallet provisioned onto a VOLATILE credential store (${report.backend}) because allowVolatileStore was set — ${report.detail}. This wallet and any funds it holds become permanently inaccessible on reboot. Never use this for a funded wallet.`,
361
+ );
362
+ return report;
363
+ }
364
+
365
+ export function validateStoredRecord(value: unknown): AutonomousWalletRecord {
366
+ if (
367
+ !isRecord(value) ||
368
+ value.version !== AUTONOMOUS_WALLET_VERSION ||
369
+ typeof value.appId !== "string" ||
370
+ value.appId.trim() === "" ||
371
+ typeof value.brokerUrl !== "string" ||
372
+ typeof value.publicKey !== "string" ||
373
+ value.publicKey.trim() === "" ||
374
+ typeof value.privateKey !== "string" ||
375
+ value.privateKey.trim() === "" ||
376
+ typeof value.provisioningNonce !== "string" ||
377
+ value.provisioningNonce.trim() === "" ||
378
+ typeof value.createdAt !== "number" ||
379
+ !Number.isSafeInteger(value.createdAt)
380
+ ) {
381
+ throw new AutonomousWalletError(
382
+ "store-failure",
383
+ "OS credential contains an invalid autonomous wallet record",
384
+ );
385
+ }
386
+ if (
387
+ value.walletId !== undefined &&
388
+ (typeof value.walletId !== "string" || value.walletId.trim() === "")
389
+ ) {
390
+ throw new AutonomousWalletError(
391
+ "store-failure",
392
+ "OS credential contains an invalid wallet id",
393
+ );
394
+ }
395
+ if (
396
+ value.policyId !== undefined &&
397
+ (typeof value.policyId !== "string" || value.policyId.trim() === "")
398
+ ) {
399
+ throw new AutonomousWalletError(
400
+ "store-failure",
401
+ "OS credential contains an invalid policy id",
402
+ );
403
+ }
404
+ if (
405
+ value.address !== undefined &&
406
+ (typeof value.address !== "string" ||
407
+ !/^0x[0-9a-fA-F]{40}$/.test(value.address))
408
+ ) {
409
+ throw new AutonomousWalletError(
410
+ "store-failure",
411
+ "OS credential contains an invalid wallet address",
412
+ );
413
+ }
414
+ try {
415
+ validateP256KeyPair(value.publicKey, value.privateKey);
416
+ } catch (error) {
417
+ throw new AutonomousWalletError(
418
+ "store-failure",
419
+ "OS credential contains invalid P-256 key material",
420
+ { cause: error },
421
+ );
422
+ }
423
+ return {
424
+ version: AUTONOMOUS_WALLET_VERSION,
425
+ appId: value.appId,
426
+ brokerUrl: value.brokerUrl,
427
+ publicKey: value.publicKey,
428
+ privateKey: value.privateKey,
429
+ provisioningNonce: value.provisioningNonce,
430
+ ...(value.walletId === undefined ? {} : { walletId: value.walletId }),
431
+ ...(value.address === undefined
432
+ ? {}
433
+ : { address: getAddress(value.address) }),
434
+ ...(value.policyId === undefined ? {} : { policyId: value.policyId }),
435
+ createdAt: value.createdAt,
436
+ };
437
+ }
438
+
439
+ export function decodeBase64(value: string): Uint8Array {
440
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 === 1) {
441
+ throw new AutonomousWalletError(
442
+ "invalid-response",
443
+ "broker returned invalid proof payload encoding",
444
+ );
445
+ }
446
+ const decoded = Buffer.from(value, "base64");
447
+ if (decoded.length === 0 || decoded.toString("base64") !== value) {
448
+ throw new AutonomousWalletError(
449
+ "invalid-response",
450
+ "broker returned an empty proof payload",
451
+ );
452
+ }
453
+ return new Uint8Array(decoded);
454
+ }
455
+
456
+ export function validateP256KeyPair(
457
+ publicKey: string,
458
+ privateKey: string,
459
+ ): void {
460
+ const publicObject = createPublicKey({
461
+ key: Buffer.from(decodeBase64(publicKey)),
462
+ format: "der",
463
+ type: "spki",
464
+ });
465
+ const privateObject = createPrivateKey({
466
+ key: Buffer.from(decodeBase64(privateKey)),
467
+ format: "der",
468
+ type: "pkcs8",
469
+ });
470
+ if (
471
+ publicObject.asymmetricKeyType !== "ec" ||
472
+ publicObject.asymmetricKeyDetails?.namedCurve !== "prime256v1" ||
473
+ privateObject.asymmetricKeyType !== "ec" ||
474
+ privateObject.asymmetricKeyDetails?.namedCurve !== "prime256v1"
475
+ ) {
476
+ throw new Error("key material is not P-256");
477
+ }
478
+ const derived = createPublicKey(privateObject)
479
+ .export({ format: "der", type: "spki" })
480
+ .toString("base64");
481
+ if (derived !== publicKey)
482
+ throw new Error("public key does not match private key");
483
+ }
484
+
485
+ export function isRecord(value: unknown): value is Record<string, unknown> {
486
+ return typeof value === "object" && value !== null && !Array.isArray(value);
487
+ }