@totalreclaw/totalreclaw 3.1.0 → 3.2.0-rc.1

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/config.ts CHANGED
@@ -95,6 +95,10 @@ export const CONFIG = {
95
95
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
96
96
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
97
97
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
98
+ // 3.2.0 onboarding state file — separate from credentials.json so it
99
+ // never contains secrets. Loaded on every plugin init + on every
100
+ // before_tool_call gate check.
101
+ onboardingStatePath: process.env.TOTALRECLAW_STATE_PATH || path.join(home, '.totalreclaw', 'state.json'),
98
102
 
99
103
  // Chain — chainId is no longer user-configurable. It is auto-detected from
100
104
  // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
package/fs-helpers.ts CHANGED
@@ -401,6 +401,10 @@ export function autoBootstrapCredentials(
401
401
  * missing, unreadable, or un-parseable — caller logs but does not throw,
402
402
  * since failing to flip the flag only means the banner might show twice,
403
403
  * not data loss.
404
+ *
405
+ * NOTE: retained for back-compat with pre-3.2.0 tests. 3.2.0 removes the
406
+ * prependContext banner entirely, so no production code path calls this
407
+ * helper anymore.
404
408
  */
405
409
  export function markFirstRunAnnouncementShown(credentialsPath: string): boolean {
406
410
  try {
@@ -415,3 +419,145 @@ export function markFirstRunAnnouncementShown(credentialsPath: string): boolean
415
419
  return false;
416
420
  }
417
421
  }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Onboarding state file (3.2.0)
425
+ // ---------------------------------------------------------------------------
426
+
427
+ /**
428
+ * 3.2.0 onboarding state file — `~/.totalreclaw/state.json` (or the path
429
+ * overridden via `TOTALRECLAW_STATE_PATH`). Separate from `credentials.json`
430
+ * so the state blob never contains the mnemonic — callers can log / stat /
431
+ * copy this file freely.
432
+ *
433
+ * Two states only, per the user's "clean-slate, simplest possible" ratification:
434
+ * - `fresh` → no usable credentials; every memory tool is gated.
435
+ * - `active` → credentials.json has a valid mnemonic; tools unblocked.
436
+ *
437
+ * The design doc's richer state machine (awaiting_onboarding_choice /
438
+ * skipped / active_unacked) was collapsed to these two on user's call.
439
+ * Re-expand only if a UX need emerges.
440
+ */
441
+ export interface OnboardingState {
442
+ onboardingState: 'fresh' | 'active';
443
+ /** ISO-8601 timestamp credentials.json was first created / confirmed. */
444
+ credentialsCreatedAt?: string;
445
+ /** Which onboarding path produced the active state. */
446
+ createdBy?: 'generate' | 'import';
447
+ /** Schema version — bump when the shape changes. */
448
+ version?: string;
449
+ }
450
+
451
+ /** Default fresh state for a machine that has never onboarded. */
452
+ export function defaultFreshState(): OnboardingState {
453
+ return { onboardingState: 'fresh', version: '3.2.0' };
454
+ }
455
+
456
+ /**
457
+ * Load the state file at `statePath`. Returns `null` on any I/O or parse
458
+ * failure. The caller decides whether to initialise a fresh state or treat
459
+ * the missing file as fresh.
460
+ */
461
+ export function loadOnboardingState(statePath: string): OnboardingState | null {
462
+ try {
463
+ if (!fs.existsSync(statePath)) return null;
464
+ const raw = fs.readFileSync(statePath, 'utf-8');
465
+ const parsed = JSON.parse(raw) as Partial<OnboardingState>;
466
+ // Validate the one required field. Anything else may be absent.
467
+ if (parsed.onboardingState !== 'fresh' && parsed.onboardingState !== 'active') {
468
+ return null;
469
+ }
470
+ return {
471
+ onboardingState: parsed.onboardingState,
472
+ credentialsCreatedAt: typeof parsed.credentialsCreatedAt === 'string' ? parsed.credentialsCreatedAt : undefined,
473
+ createdBy: parsed.createdBy === 'generate' || parsed.createdBy === 'import' ? parsed.createdBy : undefined,
474
+ version: typeof parsed.version === 'string' ? parsed.version : undefined,
475
+ };
476
+ } catch {
477
+ return null;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Write the state file atomically (temp file + rename) with mode 0600.
483
+ * Returns `true` on success, `false` on any I/O error — caller logs but
484
+ * does not throw. Failing to persist state means the plugin will re-derive
485
+ * it from credentials.json on next load, which is safe.
486
+ *
487
+ * Atomicity matters here because the state file is consumed by the
488
+ * before_tool_call gate on every tool call: a half-written file would
489
+ * force-gate real memory operations.
490
+ */
491
+ export function writeOnboardingState(statePath: string, state: OnboardingState): boolean {
492
+ try {
493
+ const dir = path.dirname(statePath);
494
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
495
+ const tmp = `${statePath}.tmp-${process.pid}-${Date.now()}`;
496
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
497
+ fs.renameSync(tmp, statePath);
498
+ return true;
499
+ } catch {
500
+ return false;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Derive the current onboarding state for this process by reading
506
+ * credentials.json. Used on plugin load + after CLI wizard writes.
507
+ *
508
+ * Rule (simplest possible, per user's clean-slate ratification):
509
+ * - credentials.json exists + extractable mnemonic is a non-empty string
510
+ * → `active`.
511
+ * - credentials.json missing OR mnemonic missing/empty/non-string
512
+ * → `fresh`.
513
+ *
514
+ * This is intentionally LAX about BIP-39 checksum validation — the wizard
515
+ * validates on write; at load time we trust the on-disk file. If the
516
+ * mnemonic has been hand-edited to garbage, `initialize()` will fail later
517
+ * at key-derivation time and surface the error via needsSetup.
518
+ *
519
+ * Does NOT require a pre-existing state file; 3.1.0 users (if any) with a
520
+ * valid credentials.json → active silently, no migration code path.
521
+ */
522
+ export function deriveStateFromCredentials(credentialsPath: string): OnboardingState['onboardingState'] {
523
+ const creds = loadCredentialsJson(credentialsPath);
524
+ const mnemonic = extractBootstrapMnemonic(creds);
525
+ return mnemonic && mnemonic.length > 0 ? 'active' : 'fresh';
526
+ }
527
+
528
+ /**
529
+ * Compute the effective onboarding state at plugin-load time. Reads the
530
+ * persisted state file if it exists AND matches what credentials.json
531
+ * implies; otherwise recomputes and writes a fresh state file.
532
+ *
533
+ * The reason we still persist a state file (rather than deriving every
534
+ * call) is to carry the `createdBy` + `credentialsCreatedAt` fields through
535
+ * process restarts — those are small but useful for diagnostics + future
536
+ * migration paths.
537
+ *
538
+ * Returns the effective state. Does not throw.
539
+ */
540
+ export function resolveOnboardingState(
541
+ credentialsPath: string,
542
+ statePath: string,
543
+ ): OnboardingState {
544
+ const implied = deriveStateFromCredentials(credentialsPath);
545
+ const persisted = loadOnboardingState(statePath);
546
+
547
+ // Happy path: persisted state matches what credentials imply → trust it.
548
+ if (persisted && persisted.onboardingState === implied) {
549
+ return persisted;
550
+ }
551
+
552
+ // Mismatch (or no persisted state): recompute from credentials, persist,
553
+ // and return. Do not overwrite a known `createdBy` if we're just
554
+ // upgrading a stale state file.
555
+ const next: OnboardingState = {
556
+ onboardingState: implied,
557
+ version: '3.2.0',
558
+ credentialsCreatedAt: persisted?.credentialsCreatedAt,
559
+ createdBy: persisted?.createdBy,
560
+ };
561
+ writeOnboardingState(statePath, next);
562
+ return next;
563
+ }