@falari/harness 0.0.0-stage → 0.8.9

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/lib/service.js ADDED
@@ -0,0 +1,1429 @@
1
+ /**
2
+ * FalariDataService — the single business entry point shared by the Agent
3
+ * tools and the Web settings/panel routes.
4
+ *
5
+ * Credentials: the Agent Key lives in the DSH credential store as the record
6
+ * `falari-harness/agent-key`; it never reaches the browser and never enters
7
+ * plugin configuration.
8
+ *
9
+ * Transfers: uploads go through the SDK's persistent upload queue so a task
10
+ * survives a Host restart, and the queue driver started here advances it.
11
+ */
12
+ import { randomUUID } from 'node:crypto';
13
+ import { createWriteStream } from 'node:fs';
14
+ import { mkdir, readFile, readdir, realpath, rm, stat, unlink, writeFile } from 'node:fs/promises';
15
+ import { dirname, isAbsolute, join, parse as parsePath, relative, resolve, sep } from 'node:path';
16
+
17
+ import { DURATION_NAMES, validateConfigPatch } from './config.js';
18
+ import { ServiceError, errorText, publicError as toPublicError } from './errors.js';
19
+ import { MIN_PIN_LENGTH, PinError, isSealed, openSecret, sealSecret, validatePin } from './pin.js';
20
+ import { sdk } from './sdk.js';
21
+
22
+ const RECORD_KEY = 'falari-harness/agent-key';
23
+ const TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
24
+ /** The chain's `max_intent_duration_seconds`: 100 years. */
25
+ const MAX_DURATION_SECONDS = 3_153_600_000;
26
+ /** Chain permanent window (30 years) plus its 20% reserve: 36 years. */
27
+ const PERMANENT_CAP_SECONDS = 946_080_000 + (946_080_000 * 20) / 100;
28
+
29
+ /** A cached status older than this triggers a background chain read. */
30
+ const STATUS_REFRESH_AFTER_SECONDS = 15;
31
+ /** Wrong-PIN budget: five misses cost a 30 second pause, then it resets. */
32
+ const PIN_MAX_FAILURES = 5;
33
+ const PIN_BLOCK_MS = 30 * 1000;
34
+ /** A start code rotation clears the in-memory secret. */
35
+ const PIN_ATTEMPT_WINDOW_MS = 10 * 60 * 1000;
36
+
37
+ /**
38
+ * One live service per process. A plugin row reload constructs a new service
39
+ * while the previous one may still have an upload tick in flight; two drivers on
40
+ * the same queue sign batch commits against the same account sequence, and the
41
+ * chain rejects the loser with code 32. Retiring the old instance first keeps a
42
+ * single writer.
43
+ */
44
+ let activeService = null;
45
+
46
+ /**
47
+ * The decrypted key outlives one service instance but not the process.
48
+ *
49
+ * A plugin row reload (saving settings, toggling the bundle, reinstalling)
50
+ * constructs a new service; without this the user had to retype the PIN for a
51
+ * change that never touched the key. The vault is keyed by the sealed record's
52
+ * ciphertext, so a different key — or a key re-sealed under a new PIN — is never
53
+ * adopted. Process exit clears it; nothing here touches disk.
54
+ */
55
+ const processVault = { key: null, ct: '', lastUsedAt: 0, unlockedAt: 0 };
56
+
57
+ function normalizeAgentKey(raw) {
58
+ const cleaned = String(raw ?? '').replace(/\s+/g, '');
59
+ if (!/^far_[A-Za-z0-9_-]+$/.test(cleaned)) {
60
+ throw new ServiceError(
61
+ 'invalid_key_format',
62
+ 'Agent Key must start with "far_" and contain only base64url characters.',
63
+ 'Copy the complete export string from the Falari wallet; do not add spaces or punctuation.',
64
+ );
65
+ }
66
+ try {
67
+ sdk.loadAgentIdentity(cleaned);
68
+ } catch (error) {
69
+ throw new ServiceError(
70
+ 'invalid_key_format',
71
+ error && error.message ? error.message : 'Agent Key is not a valid far_ export string.',
72
+ 'The string may be truncated. Copy the complete key again, or export it once more from the wallet.',
73
+ );
74
+ }
75
+ return cleaned;
76
+ }
77
+
78
+ function wei(value) {
79
+ const text = String(value ?? '0').trim();
80
+ return /^-?\d+$/.test(text) ? BigInt(text) : 0n;
81
+ }
82
+
83
+ function isActiveStatus(status) {
84
+ return status === 'creating' || status === 'queued' || status === 'uploading';
85
+ }
86
+
87
+ function isFinishedStatus(status) {
88
+ return status === 'completed' || status === 'terminated';
89
+ }
90
+
91
+ export class FalariDataService {
92
+ constructor(ctx, config, credentials) {
93
+ this.ctx = ctx;
94
+ this.config = config;
95
+ this.credentials = credentials;
96
+ this.log = ctx.logger;
97
+ this.agent = null;
98
+ this.agentKey = null;
99
+ this.driverAgent = null;
100
+ this.tempFiles = new Map();
101
+ this.dataKeys = new Map();
102
+ this.downloads = new Map();
103
+ this.statusCache = null;
104
+ this.statusRefresh = null;
105
+ // The decrypted far_ string never leaves this process and never touches disk.
106
+ this.unlockedKey = null;
107
+ this.unlockedAt = 0;
108
+ this.lastUsedAt = 0;
109
+ this.pinFailures = 0;
110
+ this.pinBlockedUntil = 0;
111
+ if (activeService && activeService !== this) {
112
+ try {
113
+ activeService.dispose();
114
+ } catch {
115
+ // an old instance that cannot be disposed must not stop the new one
116
+ }
117
+ }
118
+ activeService = this;
119
+ }
120
+
121
+ // ── lifecycle ──────────────────────────────────────────────────────────
122
+
123
+ async start() {
124
+ await mkdir(this.tempDir, { recursive: true, mode: 0o700 });
125
+ await this.cleanTemps();
126
+ try {
127
+ // A sealed key stays sealed across a Host restart: nothing to start until
128
+ // the user types the PIN.
129
+ const state = await this.readKeyState();
130
+ if (state.mode === 'legacy' && state.key) {
131
+ const agent = await this.getAgent(state.key);
132
+ this.startDriver(agent);
133
+ }
134
+ } catch (error) {
135
+ this.log?.warn?.(`falari-harness: stored Agent Key could not be used at startup: ${errorText(error)}`);
136
+ }
137
+ }
138
+
139
+ dispose() {
140
+ if (activeService === this) activeService = null;
141
+ this.stopDriver();
142
+ this.agent = null;
143
+ this.agentKey = null;
144
+ this.tempFiles.clear();
145
+ this.dataKeys.clear();
146
+ this.downloads.clear();
147
+ }
148
+
149
+ get tempDir() {
150
+ return join(this.config.dataDir, 'tmp');
151
+ }
152
+
153
+ get keyringDir() {
154
+ return join(this.config.dataDir, 'keyring');
155
+ }
156
+
157
+ get downloadDir() {
158
+ return this.config.downloadDir || join(this.config.dataDir, 'downloads');
159
+ }
160
+
161
+ // ── credentials ────────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * What the credentials store holds: nothing, a sealed secret, or the legacy
165
+ * plaintext record this plugin used before PINs existed.
166
+ */
167
+ async readKeyState() {
168
+ const record = await this.credentials.readRecord(RECORD_KEY);
169
+ if (!record || record.kind !== 'api-key') return { mode: 'none', locked: false };
170
+ if (isSealed(record)) {
171
+ // Adopt a still-fresh in-process unlock after a row reload.
172
+ if (!this.unlockedKey && processVault.key && processVault.ct && processVault.ct === record.ct) {
173
+ this.unlockedKey = processVault.key;
174
+ this.lastUsedAt = processVault.lastUsedAt;
175
+ this.unlockedAt = processVault.unlockedAt;
176
+ }
177
+ const expired = this.unlockedKey && this.idleExpired();
178
+ if (expired) {
179
+ if (await this.hasActiveUploads()) {
180
+ // An upload in progress is activity; restart the idle clock instead.
181
+ this.touch();
182
+ } else {
183
+ this.lock(`idle for ${this.idleMinutes()} minutes`);
184
+ }
185
+ }
186
+ const unlocked = Boolean(this.unlockedKey);
187
+ if (unlocked) this.touch();
188
+ return { mode: 'sealed', locked: !unlocked, key: unlocked ? this.unlockedKey : null, record };
189
+ }
190
+ const key = typeof record.key === 'string' ? record.key.trim() : '';
191
+ return key ? { mode: 'legacy', locked: false, key, record } : { mode: 'none', locked: false };
192
+ }
193
+
194
+ /** Plaintext key when one is available, else null (sealed and locked). */
195
+ async readStoredKey() {
196
+ const state = await this.readKeyState();
197
+ return state.key || null;
198
+ }
199
+
200
+ idleMinutes() {
201
+ const value = Number(this.config.lockIdleMinutes);
202
+ return Number.isFinite(value) && value > 0 ? value : 0;
203
+ }
204
+
205
+ /**
206
+ * True while any upload is queued or in flight. The auto-lock counts this as
207
+ * activity: locking mid-upload would stop the driver and strand a paid task
208
+ * until the user typed the PIN again.
209
+ */
210
+ async hasActiveUploads() {
211
+ const active = (row) => ['creating', 'queued', 'uploading'].includes(String(row && row.status ? row.status : ''));
212
+ if (this.agent) {
213
+ try {
214
+ const rows = await this.agent.uploadTasks();
215
+ if (Array.isArray(rows) && rows.some(active)) return true;
216
+ } catch {
217
+ // fall through to the ledger on disk
218
+ }
219
+ }
220
+ try {
221
+ const raw = await readFile(join(this.config.dataDir, 'upload-queue.json'), 'utf8');
222
+ const rows = JSON.parse(raw);
223
+ return Array.isArray(rows) && rows.some(active);
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ idleExpired() {
230
+ const minutes = this.idleMinutes();
231
+ if (!minutes || !this.lastUsedAt) return false;
232
+ return Date.now() - this.lastUsedAt > minutes * 60 * 1000;
233
+ }
234
+
235
+ touch() {
236
+ this.lastUsedAt = Date.now();
237
+ if (this.unlockedKey) processVault.lastUsedAt = this.lastUsedAt;
238
+ }
239
+
240
+ /** Remember (or forget) the decrypted key for later service instances. */
241
+ rememberUnlocked(key, recordCt) {
242
+ processVault.key = key;
243
+ processVault.ct = recordCt || '';
244
+ processVault.unlockedAt = Date.now();
245
+ processVault.lastUsedAt = Date.now();
246
+ }
247
+
248
+ forgetUnlocked() {
249
+ processVault.key = null;
250
+ processVault.ct = '';
251
+ processVault.unlockedAt = 0;
252
+ processVault.lastUsedAt = 0;
253
+ }
254
+
255
+ /** Drop the plaintext secret and stop the queue that would need it. */
256
+ lock(reason = 'manual') {
257
+ const wasUnlocked = Boolean(this.unlockedKey);
258
+ this.forgetUnlocked();
259
+ this.unlockedKey = null;
260
+ this.unlockedAt = 0;
261
+ this.lastUsedAt = 0;
262
+ this.agent = null;
263
+ this.agentKey = null;
264
+ this.stopDriver();
265
+ if (wasUnlocked && reason) this.log?.debug?.(`falari-harness: Agent Key locked (${reason})`);
266
+ return wasUnlocked;
267
+ }
268
+
269
+ lockState() {
270
+ const remaining = this.pinBlockedUntil > Date.now() ? Math.ceil((this.pinBlockedUntil - Date.now()) / 1000) : 0;
271
+ return {
272
+ minPinLength: MIN_PIN_LENGTH,
273
+ idleMinutes: this.idleMinutes(),
274
+ attemptsLeft: Math.max(0, PIN_MAX_FAILURES - this.pinFailures),
275
+ retryAfterSeconds: remaining,
276
+ };
277
+ }
278
+
279
+ /** Decrypt the sealed key into memory; a wrong PIN is counted and rate limited. */
280
+ async unlock(pin) {
281
+ const now = Date.now();
282
+ if (this.pinBlockedUntil > now) {
283
+ throw new ServiceError(
284
+ 'pin_blocked',
285
+ `Too many wrong PIN attempts. Wait ${Math.ceil((this.pinBlockedUntil - now) / 1000)} seconds.`,
286
+ 'The stored key is safe; the delay only slows down guessing.',
287
+ );
288
+ }
289
+ const record = await this.credentials.readRecord(RECORD_KEY);
290
+ if (!record || !isSealed(record)) {
291
+ throw new ServiceError('no_sealed_key', 'No PIN-protected Agent Key is stored.', 'Save an Agent Key with a PIN first.');
292
+ }
293
+ let key;
294
+ try {
295
+ key = await openSecret(record, pin);
296
+ } catch (error) {
297
+ this.pinFailures += 1;
298
+ if (this.pinFailures >= PIN_MAX_FAILURES) {
299
+ this.pinFailures = 0;
300
+ this.pinBlockedUntil = Date.now() + PIN_BLOCK_MS;
301
+ }
302
+ throw new ServiceError(
303
+ 'wrong_pin',
304
+ error instanceof PinError ? error.message : 'That PIN does not open this key.',
305
+ `Wrong PIN. ${this.lockState().attemptsLeft} attempt(s) left before a 30 second pause.`,
306
+ );
307
+ }
308
+ if (!/^far_[A-Za-z0-9_-]+$/.test(String(key).trim())) {
309
+ throw new ServiceError('invalid_key_format', 'The stored key is not an Agent Key export string.');
310
+ }
311
+ this.unlockedKey = String(key).trim();
312
+ this.unlockedAt = Date.now();
313
+ this.pinFailures = 0;
314
+ this.pinBlockedUntil = 0;
315
+ this.rememberUnlocked(this.unlockedKey, String(record.ct || ''));
316
+ this.touch();
317
+ const agent = await this.getAgent(this.unlockedKey);
318
+ this.startDriver(agent);
319
+ try {
320
+ const status = await agent.status();
321
+ const view = this.keyStatusFrom(status);
322
+ await this.writeStatusCache(this.unlockedKey, view);
323
+ return { ...view, security: await this.securityView() };
324
+ } catch {
325
+ // The key opened: a chain hiccup is reported by the status card, not here.
326
+ return { configured: true, network: this.config.network, security: await this.securityView() };
327
+ }
328
+ }
329
+
330
+ /** Seal the legacy plaintext record under a PIN, then unlock it. */
331
+ async secureLegacyKey(pin) {
332
+ validatePin(pin);
333
+ const record = await this.credentials.readRecord(RECORD_KEY);
334
+ if (!record) throw new ServiceError('no_agent_key', 'No Agent Key is stored yet.');
335
+ if (isSealed(record)) throw new ServiceError('already_sealed', 'This key is already protected by a PIN.');
336
+ const legacy = typeof record.key === 'string' ? record.key.trim() : '';
337
+ if (!legacy) throw new ServiceError('no_agent_key', 'No Agent Key is stored yet.');
338
+ const sealed = await sealSecret(legacy, pin);
339
+ await this.credentials.modifyRecord(RECORD_KEY, async () => ({ kind: 'api-key', ...sealed }));
340
+ this.unlockedKey = legacy;
341
+ this.unlockedAt = Date.now();
342
+ this.rememberUnlocked(legacy, String(sealed.ct || ''));
343
+ this.touch();
344
+ try {
345
+ this.startDriver(await this.getAgent(legacy));
346
+ } catch {
347
+ // Starting the queue is a convenience; a chain hiccup is not a save failure.
348
+ }
349
+ return { rotated: true, security: await this.securityView() };
350
+ }
351
+
352
+ /** What the UI needs to know about the stored secret, without the secret. */
353
+ async securityView() {
354
+ const state = await this.readKeyState();
355
+ return {
356
+ mode: state.mode,
357
+ locked: state.mode === 'sealed' ? state.locked : false,
358
+ requiresPinSetup: state.mode === 'legacy',
359
+ uploadActive: await this.hasActiveUploads(),
360
+ ...this.lockState(),
361
+ };
362
+ }
363
+
364
+ async getAgent(rawKey) {
365
+ if (this.agent && this.agentKey === rawKey) return this.agent;
366
+ this.stopDriver();
367
+ await mkdir(this.config.dataDir, { recursive: true, mode: 0o700 });
368
+ const options = {
369
+ agentKey: rawKey,
370
+ network: this.config.network,
371
+ timeoutMs: this.config.timeoutMs,
372
+ keyringDir: this.keyringDir,
373
+ uploadQueueFile: join(this.config.dataDir, 'upload-queue.json'),
374
+ defaultPrivacy: this.config.defaultPrivacy,
375
+ };
376
+ if (this.config.gatewayUrl) options.gatewayUrl = this.config.gatewayUrl;
377
+ if (this.config.retrievalUrl) options.retrievalUrl = this.config.retrievalUrl;
378
+ if (this.config.explorerUrl) options.explorerUrl = this.config.explorerUrl;
379
+ const agent = new sdk.FalariAgent(options);
380
+ this.agent = agent;
381
+ this.agentKey = rawKey;
382
+ return agent;
383
+ }
384
+
385
+ async ensureAgent() {
386
+ const state = await this.readKeyState();
387
+ if (state.mode === 'none') {
388
+ throw new ServiceError(
389
+ 'no_agent_key',
390
+ 'No Falari Agent Key is configured.',
391
+ 'Open the plugin page (Plugins → Falari Data → Configure) and paste a far_ export string.',
392
+ );
393
+ }
394
+ if (state.mode === 'legacy') {
395
+ throw new ServiceError(
396
+ 'pin_required',
397
+ 'The stored Agent Key is still plain text. Set a PIN to encrypt it before uploading.',
398
+ 'Open the plugin page, Settings → Agent Key, and set a PIN. The key is re-encrypted in place; nothing is uploaded.',
399
+ );
400
+ }
401
+ if (state.locked || !state.key) {
402
+ throw new ServiceError(
403
+ 'agent_key_locked',
404
+ 'The Agent Key is locked. Enter your PIN in the plugin panel to unlock it.',
405
+ 'The key stays encrypted on disk; the PIN is only held in memory.',
406
+ );
407
+ }
408
+ return this.getAgent(state.key);
409
+ }
410
+
411
+ async configureKey(rawKey, pin) {
412
+ const cleaned = normalizeAgentKey(rawKey);
413
+ // A key without a PIN would be stored in clear text, which other plugins can
414
+ // read; refuse it instead of quietly weakening the profile.
415
+ validatePin(pin);
416
+ const agent = await this.getAgent(cleaned);
417
+ const status = await agent.status();
418
+ if (!status.agentKey) {
419
+ throw new ServiceError(
420
+ 'agent_key_not_registered',
421
+ `This key is not registered under its master account on ${this.config.network}.`,
422
+ 'Check that this far_ string belongs to a key created on the same network, and that it has not been revoked.',
423
+ );
424
+ }
425
+ const sealed = await sealSecret(cleaned, pin);
426
+ await this.credentials.modifyRecord(RECORD_KEY, async () => ({ kind: 'api-key', ...sealed }));
427
+ this.unlockedKey = cleaned;
428
+ this.unlockedAt = Date.now();
429
+ this.pinFailures = 0;
430
+ this.pinBlockedUntil = 0;
431
+ this.rememberUnlocked(cleaned, String(sealed.ct || ''));
432
+ this.touch();
433
+ this.startDriver(agent);
434
+ const view = this.keyStatusFrom(status);
435
+ await this.writeStatusCache(cleaned, view);
436
+ return { ...view, security: await this.securityView() };
437
+ }
438
+
439
+ /**
440
+ * Read the key status, preferring the cached view so a page open is instant.
441
+ *
442
+ * The cache is written after every real chain read and reused until it ages
443
+ * past STATUS_REFRESH_AFTER_SECONDS; a stale hit kicks off one background
444
+ * refresh, so the next poll returns the newer numbers.
445
+ */
446
+ async keyStatus(options = {}) {
447
+ const security = await this.securityView();
448
+ if (security.mode === 'none') {
449
+ this.statusCache = null;
450
+ return { configured: false, network: this.config.network, security };
451
+ }
452
+ if (security.mode === 'legacy') {
453
+ return { configured: true, locked: false, security, network: this.config.network, requiresPinSetup: true };
454
+ }
455
+ if (security.locked) {
456
+ return { configured: true, locked: true, security, network: this.config.network };
457
+ }
458
+ const rawKey = await this.readStoredKey();
459
+ if (!rawKey) {
460
+ return { configured: true, locked: true, security, network: this.config.network };
461
+ }
462
+
463
+ let fingerprint;
464
+ try {
465
+ fingerprint = statusFingerprint(sdk.loadAgentIdentity(rawKey), this.config.network);
466
+ } catch (error) {
467
+ return {
468
+ configured: true,
469
+ healthy: false,
470
+ network: this.config.network,
471
+ error: toPublicError(error),
472
+ checkedAt: new Date().toISOString(),
473
+ };
474
+ }
475
+
476
+ const cached = await this.readStatusCache(fingerprint);
477
+ if (!cached || options.refresh === true) {
478
+ return { ...(await this.refreshStatus(rawKey, fingerprint, cached)), security };
479
+ }
480
+
481
+ const age = Math.floor(Date.now() / 1000) - cached.at;
482
+ if (age > STATUS_REFRESH_AFTER_SECONDS) this.scheduleStatusRefresh(rawKey, fingerprint);
483
+ return { ...cached.view, cached: true, cachedAt: cached.at, ageSeconds: age, security };
484
+ }
485
+
486
+ /** One real chain read. Writes the cache on success and keeps it on failure. */
487
+ async refreshStatus(rawKey, fingerprint, cached) {
488
+ try {
489
+ const agent = await this.getAgent(rawKey);
490
+ const status = await agent.status();
491
+ this.startDriver(agent);
492
+ const view = this.keyStatusFrom(status);
493
+ await this.writeStatusCache(rawKey, view, fingerprint);
494
+ return view;
495
+ } catch (error) {
496
+ const failure = toPublicError(error);
497
+ if (cached) {
498
+ // Keep the last good numbers on screen; report the failed read beside them.
499
+ return { ...cached.view, cached: true, cachedAt: cached.at, refreshError: failure };
500
+ }
501
+ return {
502
+ configured: true,
503
+ healthy: false,
504
+ network: this.config.network,
505
+ error: failure,
506
+ checkedAt: new Date().toISOString(),
507
+ };
508
+ }
509
+ }
510
+
511
+ scheduleStatusRefresh(rawKey, fingerprint) {
512
+ if (this.statusRefresh) return;
513
+ const run = this.refreshStatus(rawKey, fingerprint, this.statusCache && this.statusCache.fingerprint === fingerprint ? this.statusCache : null)
514
+ .catch(() => undefined)
515
+ .finally(() => {
516
+ if (this.statusRefresh === run) this.statusRefresh = null;
517
+ });
518
+ this.statusRefresh = run;
519
+ }
520
+
521
+ get statusCacheFile() {
522
+ return join(this.config.dataDir, 'status-cache.json');
523
+ }
524
+
525
+ async readStatusCache(fingerprint) {
526
+ if (this.statusCache && this.statusCache.fingerprint === fingerprint) return this.statusCache;
527
+ try {
528
+ const parsed = JSON.parse(await readFile(this.statusCacheFile, 'utf8'));
529
+ if (parsed && parsed.fingerprint === fingerprint && parsed.view) {
530
+ this.statusCache = parsed;
531
+ return parsed;
532
+ }
533
+ } catch {
534
+ // no cache yet, or it belongs to another key or network
535
+ }
536
+ return null;
537
+ }
538
+
539
+ async writeStatusCache(rawKey, view, fingerprint) {
540
+ let id;
541
+ try {
542
+ id = fingerprint || statusFingerprint(sdk.loadAgentIdentity(rawKey), this.config.network);
543
+ } catch {
544
+ return;
545
+ }
546
+ const entry = { fingerprint: id, at: Math.floor(Date.now() / 1000), view };
547
+ this.statusCache = entry;
548
+ try {
549
+ await mkdir(dirname(this.statusCacheFile), { recursive: true, mode: 0o700 });
550
+ await writeFile(this.statusCacheFile, JSON.stringify(entry, null, 2), { mode: 0o600 });
551
+ } catch {
552
+ // a read-only data directory only costs the cross-restart warm start
553
+ }
554
+ }
555
+
556
+ async clearKey() {
557
+ this.forgetUnlocked();
558
+ this.unlockedKey = null;
559
+ this.unlockedAt = 0;
560
+ this.lastUsedAt = 0;
561
+ await this.credentials.deleteRecord(RECORD_KEY);
562
+ this.stopDriver();
563
+ this.agent = null;
564
+ this.agentKey = null;
565
+ this.dataKeys.clear();
566
+ this.statusCache = null;
567
+ this.statusRefresh = null;
568
+ await rm(this.statusCacheFile, { force: true }).catch(() => undefined);
569
+ return { configured: false, network: this.config.network };
570
+ }
571
+
572
+ keyStatusFrom(status) {
573
+ const key = status && status.agentKey ? status.agentKey : null;
574
+ return {
575
+ configured: true,
576
+ healthy: Boolean(key && key.status === 'active'),
577
+ network: this.config.network,
578
+ chainId: status.chainId,
579
+ height: status.height,
580
+ owner: status.owner,
581
+ balance: status.balance,
582
+ balanceWei: status.balanceWei,
583
+ minerCount: status.minerCount,
584
+ miners: Array.isArray(status.miners) ? status.miners : [],
585
+ agentKey: key
586
+ ? {
587
+ ordinal: key.ordinal,
588
+ status: key.status,
589
+ permissions: Array.isArray(key.permissions) ? key.permissions : [],
590
+ tiers: Array.isArray(key.tiers) ? key.tiers : [],
591
+ totalLimit: key.totalLimit,
592
+ usedTotal: key.usedTotal,
593
+ dailyLimit: key.dailyLimit,
594
+ usedToday: key.usedToday,
595
+ expiresAt: key.expiresAt,
596
+ }
597
+ : null,
598
+ checkedAt: new Date().toISOString(),
599
+ };
600
+ }
601
+
602
+ /**
603
+ * Persist a settings-page edit through the profile's config editor. The edit
604
+ * restarts this plugin, so the caller reports a reload instead of pretending
605
+ * the running instance already uses the new values.
606
+ */
607
+ async updateConfig(patch) {
608
+ const editor = this.ctx.get('configEditor');
609
+ const entry = this.ctx.fiber && this.ctx.fiber.entry;
610
+ if (!editor || typeof editor.edit !== 'function' || !entry) {
611
+ throw new ServiceError(
612
+ 'config_unavailable',
613
+ 'This profile cannot save plugin configuration from the panel.',
614
+ 'Edit the falari-harness row in cordis.patch.yml instead.',
615
+ );
616
+ }
617
+ let clean;
618
+ try {
619
+ clean = validateConfigPatch(patch);
620
+ } catch (error) {
621
+ throw new ServiceError('invalid_config', errorText(error));
622
+ }
623
+ if (Object.keys(clean).length === 0) {
624
+ throw new ServiceError('nothing_to_update', 'No supported configuration field was provided.');
625
+ }
626
+ await editor.edit(entry, (current) => ({ ...(current && typeof current === 'object' ? current : {}), ...clean }));
627
+ return { applied: true, reloading: true, fields: Object.keys(clean) };
628
+ }
629
+
630
+ // ── queue driver ───────────────────────────────────────────────────────
631
+
632
+ startDriver(agent) {
633
+ if (this.driverAgent === agent) return;
634
+ this.stopDriver();
635
+ try {
636
+ agent.startUploadQueue({ pollMs: 2500, retryMs: 15000 });
637
+ this.driverAgent = agent;
638
+ } catch (error) {
639
+ this.log?.warn?.(`falari-harness: upload queue driver did not start: ${errorText(error)}`);
640
+ }
641
+ }
642
+
643
+ stopDriver() {
644
+ if (this.driverAgent) {
645
+ try {
646
+ this.driverAgent.stopUploadQueue();
647
+ } catch {
648
+ // a disposed SDK client must not block unloading the plugin
649
+ }
650
+ }
651
+ this.driverAgent = null;
652
+ }
653
+
654
+ // ── quote and budget ───────────────────────────────────────────────────
655
+
656
+ /**
657
+ * A preset name, or any whole number of seconds from 0 (permanent) up to the
658
+ * chain's maximum term. The fee arithmetic for both lives in the SDK, which
659
+ * mirrors Params.RequiredFee: whole days rounded up, and anything past 36
660
+ * years is charged the permanent price.
661
+ */
662
+ normalizeDuration(value) {
663
+ if (value === undefined || value === null || value === '') return this.config.defaultDuration;
664
+ const text = String(value).trim();
665
+ if (DURATION_NAMES.includes(text)) return text;
666
+ if (/^\d+$/.test(text)) {
667
+ const seconds = Number(text);
668
+ if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > MAX_DURATION_SECONDS) {
669
+ throw new ServiceError(
670
+ 'invalid_duration',
671
+ `A custom duration must be between 0 and ${MAX_DURATION_SECONDS} seconds.`,
672
+ '0 means permanent; the chain caps a finite term at 100 years.',
673
+ );
674
+ }
675
+ return seconds;
676
+ }
677
+ throw new ServiceError(
678
+ 'invalid_duration',
679
+ `unknown duration "${value}"`,
680
+ `Use one of ${DURATION_NAMES.join(', ')} or a number of seconds.`,
681
+ );
682
+ }
683
+
684
+ async quote(input = {}) {
685
+ const agent = await this.ensureAgent();
686
+ const size = Number(input.sizeBytes);
687
+ if (!Number.isFinite(size) || size <= 0) {
688
+ throw new ServiceError('invalid_size', 'A positive file size is required for a quote.');
689
+ }
690
+ if (size > this.config.maxFileSizeBytes) {
691
+ throw new ServiceError(
692
+ 'file_too_large',
693
+ `The file is ${size} bytes, above the configured limit of ${this.config.maxFileSizeBytes}.`,
694
+ 'Raise maxFileSizeBytes in the plugin configuration to allow larger uploads.',
695
+ );
696
+ }
697
+ const duration = this.normalizeDuration(input.duration);
698
+ const quote = await agent.quote({ size, duration });
699
+ const requiredWei = sdk.uploadAllowanceRequiredWei(null, BigInt(quote.estimatedTotalFeeWei));
700
+ const budget = await this.budgetCheck(agent, requiredWei);
701
+ // Billing is per whole day, rounded up, minimum one day; the price of a
702
+ // term past the permanent window collapses to the permanent price, so the
703
+ // effective term becomes "permanent" there too.
704
+ const pricedAsPermanent = Boolean(quote.permanent) || quote.seconds > PERMANENT_CAP_SECONDS;
705
+ const billableDays = pricedAsPermanent ? 30 * 365 : Math.max(1, Math.ceil(quote.seconds / 86400));
706
+ return {
707
+ sizeBytes: size,
708
+ durationSeconds: pricedAsPermanent ? 0 : quote.seconds,
709
+ permanent: pricedAsPermanent,
710
+ billableDays,
711
+ durationLimits: { maxSeconds: MAX_DURATION_SECONDS, permanentCapSeconds: PERMANENT_CAP_SECONDS },
712
+ requestedDuration: String(duration),
713
+ estimatedLockFee: quote.estimatedLockFee,
714
+ estimatedLockFeeWei: quote.estimatedLockFeeWei,
715
+ estimatedTotalFee: quote.estimatedTotalFee,
716
+ estimatedTotalFeeWei: quote.estimatedTotalFeeWei,
717
+ nonRefundableFee: quote.nonRefundableFee,
718
+ nonRefundableFeeWei: quote.nonRefundableFeeWei,
719
+ feeBreakdown: quote.feeBreakdown,
720
+ note: quote.note,
721
+ requiredAllowanceWei: requiredWei.toString(),
722
+ budget,
723
+ quotedAt: new Date().toISOString(),
724
+ };
725
+ }
726
+
727
+ async budgetCheck(agent, requiredWei) {
728
+ const [allowanceResult, statusResult] = await Promise.allSettled([
729
+ agent.agentKeyAllowance(),
730
+ agent.status(),
731
+ ]);
732
+ const allowance = allowanceResult.status === 'fulfilled' ? allowanceResult.value : null;
733
+ const status = statusResult.status === 'fulfilled' ? statusResult.value : null;
734
+ const shortfall = allowance ? sdk.uploadAllowanceShortfall(allowance, requiredWei) : null;
735
+ const balanceWei = status ? wei(status.balanceWei) : 0n;
736
+ const tiers = status && status.agentKey && Array.isArray(status.agentKey.tiers) ? status.agentKey.tiers : [];
737
+ const canUpload = tiers.length === 0 ? null : tiers.includes('write_data');
738
+ const totalLimit = allowance ? wei(allowance.total_limit) : 0n;
739
+ const dailyLimit = allowance ? wei(allowance.daily_limit) : 0n;
740
+ const totalRemaining = totalLimit === -1n ? '-1' : (totalLimit - wei(allowance.total_used)).toString();
741
+ const dailyRemaining = dailyLimit === -1n ? '-1' : (dailyLimit - wei(allowance.daily_used)).toString();
742
+ let reason = null;
743
+ if (canUpload === false) reason = 'permission_missing';
744
+ else if (shortfall === 'total') reason = 'allowance_total';
745
+ else if (shortfall === 'daily') reason = 'allowance_daily';
746
+ else if (status && balanceWei < requiredWei) reason = 'balance';
747
+ return {
748
+ ok: reason === null,
749
+ reason,
750
+ requiredWei: requiredWei.toString(),
751
+ allowanceTotalRemainingWei: totalRemaining,
752
+ allowanceDailyRemainingWei: dailyRemaining,
753
+ balanceWei: status ? String(status.balanceWei ?? '0') : null,
754
+ canUpload,
755
+ };
756
+ }
757
+
758
+ // ── uploads ────────────────────────────────────────────────────────────
759
+
760
+ async assertUploadableFile(path) {
761
+ let info;
762
+ try {
763
+ info = await stat(path);
764
+ } catch {
765
+ throw new ServiceError('source_missing', `File not found: ${path}`);
766
+ }
767
+ if (!info.isFile()) throw new ServiceError('source_not_file', `Not a regular file: ${path}`);
768
+ if (info.size <= 0) throw new ServiceError('empty_file', 'Refusing to upload an empty file.');
769
+ if (info.size > this.config.maxFileSizeBytes) {
770
+ throw new ServiceError(
771
+ 'file_too_large',
772
+ `The file is ${info.size} bytes, above the configured limit of ${this.config.maxFileSizeBytes}.`,
773
+ 'Raise maxFileSizeBytes in the plugin configuration to allow larger uploads.',
774
+ );
775
+ }
776
+ return info;
777
+ }
778
+
779
+ async enqueueUpload(input = {}) {
780
+ const agent = await this.ensureAgent();
781
+ const source = String(input.path ?? '').trim();
782
+ if (!source) throw new ServiceError('missing_path', 'An upload needs a file path.');
783
+ const absolute = resolve(source);
784
+ const info = await this.assertUploadableFile(absolute);
785
+ const privacy = input.privacy === 'private' ? 'private' : input.privacy === 'public' ? 'public' : this.config.defaultPrivacy;
786
+ const duration = this.normalizeDuration(input.duration);
787
+ const fileName = String(input.fileName ?? '').trim() || undefined;
788
+
789
+ const quoted = await this.quote({ sizeBytes: info.size, duration });
790
+ if (!quoted.budget.ok) {
791
+ throw new ServiceError(
792
+ 'budget_shortfall',
793
+ budgetMessage(quoted.budget),
794
+ budgetHint(quoted.budget),
795
+ );
796
+ }
797
+
798
+ const task = await agent.enqueueUpload(absolute, {
799
+ privacy,
800
+ duration,
801
+ ...(fileName ? { fileName } : {}),
802
+ ...(input.dataKey ? { dataKey: input.dataKey } : {}),
803
+ resume: input.resume !== false,
804
+ });
805
+ if (input.temp === true) this.tempFiles.set(task.id, absolute);
806
+ this.startDriver(agent);
807
+ return this.taskView(task);
808
+ }
809
+
810
+ /**
811
+ * Persist one browser upload into the plugin's temp directory, then queue it.
812
+ * The bytes stream in, so a multi-gigabyte body never sits in memory.
813
+ */
814
+ async saveUploadStream(stream, options = {}) {
815
+ const fileName = sanitizeFileName(options.fileName);
816
+ if (!fileName) throw new ServiceError('missing_file_name', 'An upload needs a file name.');
817
+ await mkdir(this.tempDir, { recursive: true, mode: 0o700 });
818
+ const tempPath = join(this.tempDir, `${randomUUID()}.bin`);
819
+ const sink = createWriteStream(tempPath, { flags: 'wx', mode: 0o600 });
820
+ let size = 0;
821
+ let tooLarge = false;
822
+ let failure = null;
823
+ sink.on('error', (error) => {
824
+ failure = failure || error;
825
+ });
826
+ try {
827
+ for await (const chunk of stream) {
828
+ size += chunk.length;
829
+ if (size > this.config.maxFileSizeBytes) {
830
+ tooLarge = true;
831
+ break;
832
+ }
833
+ if (!sink.write(chunk)) {
834
+ await new Promise((done) => sink.once('drain', done));
835
+ }
836
+ if (failure) break;
837
+ }
838
+ } catch (error) {
839
+ failure = failure || error;
840
+ }
841
+ await new Promise((done) => sink.end(done));
842
+ if (tooLarge || failure || size === 0) {
843
+ await unlink(tempPath).catch(() => undefined);
844
+ if (tooLarge) {
845
+ throw new ServiceError(
846
+ 'file_too_large',
847
+ `The file is above the configured limit of ${this.config.maxFileSizeBytes} bytes.`,
848
+ 'Raise maxFileSizeBytes in the plugin configuration to allow larger uploads.',
849
+ );
850
+ }
851
+ if (failure) throw failure;
852
+ throw new ServiceError('empty_file', 'Refusing to upload an empty file.');
853
+ }
854
+ try {
855
+ const task = await this.enqueueUpload({
856
+ path: tempPath,
857
+ fileName,
858
+ privacy: options.privacy,
859
+ duration: options.duration,
860
+ temp: true,
861
+ });
862
+ return { ...task, fileSize: size };
863
+ } catch (error) {
864
+ await unlink(tempPath).catch(() => undefined);
865
+ throw error;
866
+ }
867
+ }
868
+
869
+ async listTasks() {
870
+ let agent;
871
+ try {
872
+ agent = await this.ensureAgent();
873
+ } catch (error) {
874
+ if (error instanceof ServiceError && error.code === 'no_agent_key') return [];
875
+ throw error;
876
+ }
877
+ const rows = await agent.uploadTasks();
878
+ const views = await Promise.all(rows.map((row) => this.taskView(row)));
879
+ const merged = [...views, ...this.downloads.values()].map((task) => ({ ...task }));
880
+ return merged.sort((left, right) => right.createdAt - left.createdAt || right.updatedAt - left.updatedAt);
881
+ }
882
+
883
+ async getTask(id) {
884
+ const tasks = await this.listTasks();
885
+ const found = tasks.find((task) => task.id === id);
886
+ if (!found) throw new ServiceError('task_not_found', `No upload task with id ${id}.`);
887
+ return found;
888
+ }
889
+
890
+ async taskAction(id, action) {
891
+ const download = this.downloads.get(id);
892
+ if (download) {
893
+ if (action === 'remove') {
894
+ this.downloads.delete(id);
895
+ return { id, removed: true };
896
+ }
897
+ if (action === 'retry') {
898
+ return this.startDownload({ intentId: download.intentId, outPath: download.outPath });
899
+ }
900
+ throw new ServiceError(
901
+ 'unsupported_action',
902
+ 'A download cannot be paused or cancelled. Start it again if it failed.',
903
+ );
904
+ }
905
+ const agent = await this.ensureAgent();
906
+ if (action === 'pause') {
907
+ await agent.pauseUpload(id);
908
+ } else if (action === 'resume') {
909
+ await agent.resumeUpload(id);
910
+ } else if (action === 'retry') {
911
+ await agent.retryUpload(id);
912
+ } else if (action === 'cancel') {
913
+ await agent.terminateUploadTask(id, 'user_stop');
914
+ } else if (action === 'remove') {
915
+ const removed = await agent.removeUploadTask(id);
916
+ if (!removed) {
917
+ throw new ServiceError('task_not_finished', 'Only a completed or terminated task can be removed.');
918
+ }
919
+ await this.dropTempFile(id);
920
+ } else {
921
+ throw new ServiceError('unknown_action', `Unknown task action "${action}".`);
922
+ }
923
+ try {
924
+ return await this.getTask(id);
925
+ } catch (error) {
926
+ if (action === 'remove' && error instanceof ServiceError && error.code === 'task_not_found') {
927
+ return { id, removed: true };
928
+ }
929
+ throw error;
930
+ }
931
+ }
932
+
933
+ /**
934
+ * What cancelling this upload returns, before anything is signed: the escrow
935
+ * the chain holds for the intent, the non-refundable compensation it charges
936
+ * for a deal that already committed shards, and the service fee billed
937
+ * separately from the master balance.
938
+ *
939
+ * The numbers mirror keeper.closeUnfinalizedIntent: a voluntary close reads
940
+ * voluntary_cancellation_fee_bps, clamps the fee to min_cancellation_fee and
941
+ * never charges more than the escrow. An intent with no receipt yet is closed
942
+ * without compensation, so the whole escrow comes back.
943
+ */
944
+ async refundPreview(id) {
945
+ const task = await this.getTask(id);
946
+ if (task.kind !== 'upload') {
947
+ throw new ServiceError('not_an_upload', 'Only an upload holds an escrow to refund.');
948
+ }
949
+ const base = {
950
+ taskId: id,
951
+ intentId: task.intentId || null,
952
+ fileName: task.fileName,
953
+ fileSize: task.fileSize,
954
+ status: task.status,
955
+ escrowWei: '0',
956
+ cancellationFeeWei: '0',
957
+ refundWei: '0',
958
+ serviceFeeWei: '0',
959
+ paidFeeWei: '0',
960
+ uploadedSize: 0,
961
+ committedSegments: 0,
962
+ hasReceipts: false,
963
+ cancellationBps: 0,
964
+ minCancellationFeeWei: '0',
965
+ terminated: false,
966
+ refundedFeeWei: null,
967
+ estimated: true,
968
+ };
969
+ if (!task.intentId) {
970
+ return { ...base, hasIntent: false, cancelling: 'local_only' };
971
+ }
972
+ const agent = await this.ensureAgent();
973
+ const [intent, params] = await Promise.all([
974
+ agent.api.getIntent(task.intentId).catch(() => null),
975
+ agent.api.getStorageParams().catch(() => null),
976
+ ]);
977
+ if (!intent) {
978
+ return {
979
+ ...base,
980
+ hasIntent: true,
981
+ estimated: false,
982
+ unavailable: 'intent_unreadable',
983
+ };
984
+ }
985
+
986
+ const escrow = bigWei(intent.locked_fee_wei ?? intent.locked_fee);
987
+ const paid = bigWei(intent.paid_fee_wei ?? intent.paid_fee);
988
+ const uploadedSize = Number(intent.uploaded_size ?? 0);
989
+ const committedSegments = Number(intent.committed_segments ?? 0);
990
+ const hasReceipts = uploadedSize > 0 || committedSegments > 0;
991
+ const bps = bigWei(
992
+ params?.voluntary_cancellation_fee_bps ?? params?.cancellation_fee_bps ?? 0,
993
+ );
994
+ const minFee = bigWei(params?.min_cancellation_fee ?? 0);
995
+ const serviceFee = bigWei(params?.service_fees && params.service_fees.terminate);
996
+
997
+ const finalized = String(intent.status || '') === 'finalized';
998
+ if (finalized) {
999
+ return {
1000
+ ...base,
1001
+ hasIntent: true,
1002
+ status: intent.status,
1003
+ storageStatus: intent.storage_status || '',
1004
+ uploadedSize,
1005
+ committedSegments,
1006
+ hasReceipts,
1007
+ paidFeeWei: paid.toString(),
1008
+ finalized: true,
1009
+ estimated: false,
1010
+ unavailable: 'finalized',
1011
+ };
1012
+ }
1013
+ let cancellation = 0n;
1014
+ if (hasReceipts && escrow > 0n) {
1015
+ cancellation = (escrow * bps) / 10000n;
1016
+ if (cancellation < minFee) cancellation = minFee;
1017
+ if (cancellation > escrow) cancellation = escrow;
1018
+ }
1019
+ const refund = escrow > cancellation ? escrow - cancellation : 0n;
1020
+ const terminated = String(intent.status || '') === 'terminated';
1021
+
1022
+ return {
1023
+ ...base,
1024
+ hasIntent: true,
1025
+ status: intent.status || task.status,
1026
+ storageStatus: intent.storage_status || '',
1027
+ escrowWei: escrow.toString(),
1028
+ paidFeeWei: paid.toString(),
1029
+ uploadedSize,
1030
+ committedSegments,
1031
+ hasReceipts,
1032
+ cancellationBps: Number(bps),
1033
+ minCancellationFeeWei: minFee.toString(),
1034
+ cancellationFeeWei: (terminated ? bigWei(intent.locked_fee_wei) : cancellation).toString(),
1035
+ refundWei: (terminated ? bigWei(intent.refunded_fee_wei ?? intent.refunded_fee) : refund).toString(),
1036
+ serviceFeeWei: serviceFee.toString(),
1037
+ terminated,
1038
+ refundedFeeWei: terminated ? bigWei(intent.refunded_fee_wei ?? intent.refunded_fee).toString() : null,
1039
+ estimated: !terminated,
1040
+ };
1041
+ }
1042
+
1043
+ /** Cancel an upload and report what the chain actually released. */
1044
+ async cancelUpload(id) {
1045
+ // The preview is a nicety: a chain read that hiccups must not stop the
1046
+ // termination, which is the thing that actually frees the escrow.
1047
+ const preview = await this.refundPreview(id).catch(() => null);
1048
+ await this.taskAction(id, 'cancel');
1049
+ const task = await this.getTask(id).catch(() => null);
1050
+ let refund = preview;
1051
+ if (preview && preview.hasIntent && !preview.terminated) {
1052
+ const agent = await this.ensureAgent();
1053
+ let confirmed = false;
1054
+ for (let attempt = 0; attempt < 8; attempt += 1) {
1055
+ const intent = await agent.api.getIntent(preview.intentId).catch(() => null);
1056
+ if (intent && String(intent.status || '') === 'terminated') {
1057
+ const refunded = bigWei(intent.refunded_fee_wei ?? intent.refunded_fee);
1058
+ refund = {
1059
+ ...preview,
1060
+ status: intent.status,
1061
+ storageStatus: intent.storage_status || '',
1062
+ executed: true,
1063
+ pending: false,
1064
+ estimated: false,
1065
+ refundWei: refunded.toString(),
1066
+ refundedFeeWei: refunded.toString(),
1067
+ escrowWei: (refunded + bigWei(intent.burned_fee_wei ?? intent.burned_fee)).toString(),
1068
+ };
1069
+ confirmed = true;
1070
+ break;
1071
+ }
1072
+ await new Promise((done) => setTimeout(done, 700));
1073
+ }
1074
+ if (!confirmed) {
1075
+ // The transaction was accepted but the intent has not flipped yet: say
1076
+ // that, rather than showing the estimate as if it were the settlement.
1077
+ refund = { ...preview, executed: true, pending: true, estimated: true };
1078
+ }
1079
+ }
1080
+ return { task, refund };
1081
+ }
1082
+
1083
+ async taskView(row) {
1084
+ const dataKey = row.privacy === 'private' && row.intentId ? await this.dataKeyFor(row.intentId) : null;
1085
+ const percent =
1086
+ row.status === 'completed'
1087
+ ? 100
1088
+ : row.bytesTotal > 0
1089
+ ? Math.max(0, Math.min(99, Math.round((row.bytesDone / row.bytesTotal) * 100)))
1090
+ : row.segmentsTotal > 0
1091
+ ? Math.max(0, Math.min(99, Math.round((row.segmentsDone / row.segmentsTotal) * 100)))
1092
+ : 0;
1093
+ if (isFinishedStatus(row.status)) {
1094
+ await this.dropTempFile(row.id);
1095
+ }
1096
+ return {
1097
+ id: row.id,
1098
+ kind: 'upload',
1099
+ status: row.status,
1100
+ stage: row.stage || '',
1101
+ fileName: row.fileName,
1102
+ fileSize: row.fileSize,
1103
+ bytesDone: row.bytesDone,
1104
+ bytesTotal: row.bytesTotal || row.fileSize,
1105
+ segmentsDone: row.segmentsDone,
1106
+ segmentsTotal: row.segmentsTotal,
1107
+ percent,
1108
+ privacy: row.privacy,
1109
+ durationSeconds: row.durationSeconds,
1110
+ intentId: row.intentId || null,
1111
+ dataKey,
1112
+ attempt: row.attempt,
1113
+ deadlineUnix: row.deadlineUnix,
1114
+ stopRequested: Boolean(row.stopRequested),
1115
+ error: row.lastError ? { code: 'upload_failed', message: row.lastError } : null,
1116
+ createdAt: row.createdAt,
1117
+ updatedAt: row.updatedAt,
1118
+ completedAt: row.completedAt || null,
1119
+ };
1120
+ }
1121
+
1122
+ async dataKeyFor(intentId) {
1123
+ const cached = this.dataKeys.get(intentId);
1124
+ if (cached !== undefined) return cached;
1125
+ try {
1126
+ const raw = await readFile(join(this.keyringDir, `${intentId}.json`), 'utf8');
1127
+ const parsed = JSON.parse(raw);
1128
+ const value = typeof parsed.dataKey === 'string' && parsed.dataKey ? parsed.dataKey : null;
1129
+ this.dataKeys.set(intentId, value);
1130
+ return value;
1131
+ } catch {
1132
+ this.dataKeys.set(intentId, null);
1133
+ return null;
1134
+ }
1135
+ }
1136
+
1137
+ // ── intents and downloads ──────────────────────────────────────────────
1138
+
1139
+ async listIntents(options = {}) {
1140
+ const agent = await this.ensureAgent();
1141
+ const limit = Number.isFinite(options.limit) && options.limit > 0 ? Math.min(200, Math.trunc(options.limit)) : 50;
1142
+ const rows = await agent.list({ limit });
1143
+ const views = await Promise.all(rows.map((row) => this.intentView(row)));
1144
+ return views;
1145
+ }
1146
+
1147
+ async getIntent(intentId) {
1148
+ const agent = await this.ensureAgent();
1149
+ return this.intentView(await agent.info(String(intentId)));
1150
+ }
1151
+
1152
+ async intentView(row) {
1153
+ const encrypted = Boolean(row.encrypted);
1154
+ let fileName = String(row.fileName ?? '');
1155
+ if (encrypted && fileName.startsWith('enc:')) {
1156
+ // The chain stores the sealed name; only this machine's keyring can open
1157
+ // it without a network round trip.
1158
+ const dataKey = await this.dataKeyFor(row.intentId);
1159
+ if (dataKey) {
1160
+ try {
1161
+ fileName = await sdk.decryptIntentName(fileName, Buffer.from(dataKey, 'base64'), String(row.fileRoot ?? ''));
1162
+ } catch {
1163
+ fileName = row.intentId;
1164
+ }
1165
+ } else {
1166
+ fileName = row.intentId;
1167
+ }
1168
+ }
1169
+ return {
1170
+ intentId: row.intentId,
1171
+ fileName,
1172
+ size: Number(row.size ?? 0),
1173
+ status: row.status,
1174
+ storageStatus: row.storageStatus,
1175
+ accessStatus: row.accessStatus,
1176
+ privacy: encrypted ? 'private' : 'public',
1177
+ encrypted,
1178
+ collectionId: row.collectionId || null,
1179
+ durationSeconds: Number(row.durationSeconds ?? 0),
1180
+ expiresAtUnix: Number(row.expiresAtUnix ?? 0),
1181
+ createdAtUnix: Number(row.createdAtUnix ?? 0),
1182
+ lockedFeeWei: String(row.lockedFeeWei ?? '0'),
1183
+ url: row.url || null,
1184
+ };
1185
+ }
1186
+
1187
+ /**
1188
+ * Start a download. The SDK call aggregates the object in memory and writes
1189
+ * the file once it verifies, so this runs as a task and reports progress.
1190
+ */
1191
+ async startDownload(input = {}) {
1192
+ const intentId = String(input.intentId ?? '').trim();
1193
+ if (!intentId) throw new ServiceError('missing_intent', 'An intent id is required to download.');
1194
+ const agent = await this.ensureAgent();
1195
+ const info = await this.intentView(await agent.info(intentId));
1196
+ const target = await this.resolveDownloadTarget(info, input.outPath);
1197
+ const now = Math.floor(Date.now() / 1000);
1198
+ const task = {
1199
+ id: `dl_${randomUUID()}`,
1200
+ kind: 'download',
1201
+ status: 'running',
1202
+ stage: 'downloading',
1203
+ fileName: info.fileName,
1204
+ fileSize: info.size,
1205
+ bytesDone: 0,
1206
+ bytesTotal: info.size,
1207
+ segmentsDone: 0,
1208
+ segmentsTotal: 0,
1209
+ percent: 0,
1210
+ privacy: info.privacy,
1211
+ durationSeconds: info.durationSeconds,
1212
+ intentId,
1213
+ dataKey: null,
1214
+ outPath: target,
1215
+ attempt: 1,
1216
+ deadlineUnix: 0,
1217
+ stopRequested: false,
1218
+ error: null,
1219
+ createdAt: now,
1220
+ updatedAt: now,
1221
+ completedAt: null,
1222
+ };
1223
+ this.downloads.set(task.id, task);
1224
+ this.pruneDownloads();
1225
+ void this.runDownload(task, agent, input.dataKey);
1226
+ return { ...task };
1227
+ }
1228
+
1229
+ async runDownload(task, agent, dataKey) {
1230
+ try {
1231
+ const result = await agent.download(task.intentId, {
1232
+ out: task.outPath,
1233
+ ...(dataKey ? { dataKey: String(dataKey) } : {}),
1234
+ onProgress: (event) => {
1235
+ if (!event || typeof event.percent !== 'number') return;
1236
+ task.percent = Math.max(0, Math.min(99, Math.round(event.percent)));
1237
+ task.stage = event.stage || task.stage;
1238
+ task.bytesDone = Math.round((task.bytesTotal * task.percent) / 100);
1239
+ task.updatedAt = Math.floor(Date.now() / 1000);
1240
+ },
1241
+ });
1242
+ task.status = 'completed';
1243
+ task.stage = 'done';
1244
+ task.percent = 100;
1245
+ task.fileName = result.fileName || task.fileName;
1246
+ task.fileSize = result.size;
1247
+ task.bytesTotal = result.size;
1248
+ task.bytesDone = result.size;
1249
+ task.outPath = result.path || task.outPath;
1250
+ task.completedAt = Math.floor(Date.now() / 1000);
1251
+ task.updatedAt = task.completedAt;
1252
+ } catch (error) {
1253
+ task.status = 'failed';
1254
+ task.stage = 'failed';
1255
+ task.error = toPublicError(error);
1256
+ task.updatedAt = Math.floor(Date.now() / 1000);
1257
+ }
1258
+ }
1259
+
1260
+ pruneDownloads() {
1261
+ if (this.downloads.size <= 50) return;
1262
+ const entries = [...this.downloads.values()].sort((left, right) => left.createdAt - right.createdAt);
1263
+ for (const entry of entries) {
1264
+ if (this.downloads.size <= 50) break;
1265
+ if (entry.status === 'running') continue;
1266
+ this.downloads.delete(entry.id);
1267
+ }
1268
+ }
1269
+
1270
+ async resolveDownloadTarget(info, outPath) {
1271
+ const dir = this.downloadDir;
1272
+ await mkdir(dir, { recursive: true, mode: 0o700 });
1273
+ const wanted = typeof outPath === 'string' && outPath.trim() ? outPath.trim() : '';
1274
+ let target;
1275
+ if (wanted) {
1276
+ const absolute = isAbsolute(wanted) ? wanted : join(dir, wanted);
1277
+ const rel = relative(dir, absolute);
1278
+ const inside = rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
1279
+ if (!inside) {
1280
+ throw new ServiceError(
1281
+ 'path_outside_download_dir',
1282
+ `Refusing to write ${absolute}: downloads stay inside ${dir}.`,
1283
+ 'Set downloadDir in the plugin configuration to change where files land.',
1284
+ );
1285
+ }
1286
+ target = absolute;
1287
+ } else {
1288
+ const name = sanitizeFileName(info.fileName) || sanitizeFileName(info.intentId) || 'download.bin';
1289
+ target = join(dir, name);
1290
+ }
1291
+ return uniquePath(target);
1292
+ }
1293
+
1294
+ // ── workspace guard (Agent tool uploads only) ──────────────────────────
1295
+
1296
+ async sessionDirectory(agent) {
1297
+ const workingDirectory = this.ctx.get('workingDirectory');
1298
+ if (workingDirectory && typeof workingDirectory.ensure === 'function' && agent) {
1299
+ try {
1300
+ return await workingDirectory.ensure(agent);
1301
+ } catch {
1302
+ // fall through to the deployment default
1303
+ }
1304
+ }
1305
+ if (workingDirectory && typeof workingDirectory.defaultDirectory === 'string') {
1306
+ return workingDirectory.defaultDirectory;
1307
+ }
1308
+ return process.cwd();
1309
+ }
1310
+
1311
+ /**
1312
+ * Resolve a model-supplied path. Unless the profile explicitly allows it, the
1313
+ * target must stay inside the Session's working directory — a model must not
1314
+ * be able to read and pay for an arbitrary host file.
1315
+ */
1316
+ async guardedSourcePath(agent, input) {
1317
+ const base = await this.sessionDirectory(agent);
1318
+ const target = resolve(base, String(input ?? ''));
1319
+ if (this.config.allowUploadOutsideWorkspace) return target;
1320
+ let baseReal = base;
1321
+ try {
1322
+ baseReal = await realpath(base);
1323
+ } catch {
1324
+ // keep the unresolved base
1325
+ }
1326
+ let targetReal = target;
1327
+ try {
1328
+ targetReal = await realpath(target);
1329
+ } catch {
1330
+ throw new ServiceError('source_missing', `File not found: ${target}`);
1331
+ }
1332
+ const rel = relative(baseReal, targetReal);
1333
+ const outside = rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel);
1334
+ if (outside) {
1335
+ throw new ServiceError(
1336
+ 'path_outside_workspace',
1337
+ `Refusing to upload ${target}: the path is outside this session's working directory (${baseReal}).`,
1338
+ 'Copy the file into the workspace first, or ask the user to upload it from the Falari Data panel.',
1339
+ );
1340
+ }
1341
+ return targetReal;
1342
+ }
1343
+
1344
+ // ── temp files ─────────────────────────────────────────────────────────
1345
+
1346
+ async dropTempFile(taskId) {
1347
+ const path = this.tempFiles.get(taskId);
1348
+ if (!path) return;
1349
+ this.tempFiles.delete(taskId);
1350
+ await unlink(path).catch(() => undefined);
1351
+ }
1352
+
1353
+ async cleanTemps() {
1354
+ let names = [];
1355
+ try {
1356
+ names = await readdir(this.tempDir);
1357
+ } catch {
1358
+ return;
1359
+ }
1360
+ const cutoff = Date.now() - TEMP_MAX_AGE_MS;
1361
+ for (const name of names) {
1362
+ const path = join(this.tempDir, name);
1363
+ try {
1364
+ const info = await stat(path);
1365
+ if (info.isFile() && info.mtimeMs < cutoff) await rm(path, { force: true });
1366
+ } catch {
1367
+ // a concurrently removed file is not an error
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ }
1373
+
1374
+ function budgetMessage(budget) {
1375
+ if (budget.reason === 'permission_missing') return 'This Agent Key has no write_data tier, so it cannot upload.';
1376
+ if (budget.reason === 'allowance_total') return 'The Agent Key total allowance does not cover this upload.';
1377
+ if (budget.reason === 'allowance_daily') return 'The Agent Key daily allowance does not cover this upload.';
1378
+ if (budget.reason === 'balance') return 'The owner account balance does not cover this upload.';
1379
+ return 'The upload budget check failed.';
1380
+ }
1381
+
1382
+ function budgetHint(budget) {
1383
+ if (budget.reason === 'permission_missing') return 'Ask the owner to issue a key with write_data.';
1384
+ if (budget.reason === 'allowance_total') return 'Raise the key total limit, or use a shorter storage duration.';
1385
+ if (budget.reason === 'allowance_daily') return 'Raise the daily limit, or retry after the UTC reset.';
1386
+ if (budget.reason === 'balance') return 'Top up the owner account.';
1387
+ return '';
1388
+ }
1389
+
1390
+ /** Parse a wei amount from either a "_wei" string or a decimal string. */
1391
+ function bigWei(value) {
1392
+ if (value === null || value === undefined || value === '') return 0n;
1393
+ try {
1394
+ return BigInt(String(value).trim());
1395
+ } catch {
1396
+ return 0n;
1397
+ }
1398
+ }
1399
+
1400
+ function statusFingerprint(identity, network) {
1401
+ return `${network}|${String(identity.master || '').toLowerCase()}|${String(identity.address || '').toLowerCase()}`;
1402
+ }
1403
+
1404
+ async function exists(path) {
1405
+ try {
1406
+ await stat(path);
1407
+ return true;
1408
+ } catch {
1409
+ return false;
1410
+ }
1411
+ }
1412
+
1413
+ async function uniquePath(target) {
1414
+ const { dir, name, ext } = parsePath(target);
1415
+ let candidate = target;
1416
+ let index = 1;
1417
+ while (await exists(candidate)) {
1418
+ candidate = join(dir, `${name} (${index})${ext}`);
1419
+ index += 1;
1420
+ }
1421
+ return candidate;
1422
+ }
1423
+
1424
+ export function sanitizeFileName(raw) {
1425
+ const base = String(raw ?? '').trim().split(/[\\/]/).pop() ?? '';
1426
+ const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, '').trim();
1427
+ if (!cleaned || cleaned === '.' || cleaned === '..') return '';
1428
+ return cleaned.slice(0, 255);
1429
+ }