/** * daemon-credential-migration.ts, lifting credentials already stranded in a * surface silo up into the daemon tier. * * Routing new writes correctly fixes nothing for the person who already ran * setup. The owner did: they ran `/google adopt` in the agent, it reported * success, and the credential landed where that build put it. Telling them to * run it again is not a fix, and neither is a daemon that starts up next to a * working credential it cannot see. * * So on start, every daemon-needed credential found in any surface, project or * user store on this machine is copied up. * * This used to say "daemon or surface, whichever comes first". Nothing on the * surface side ever called it, and the claim hid the consequence: the daemon * enumerated only its OWN surface root, so a credential a pre-fix agent left in * `~/.goodvibes/agent/` was invisible to the one thing that could lift it and * was never lifted by anything, ever. That was the owner's exact situation. * * Both halves are now true. `listDetailedForMigration` reaches every surface's * silo, so the daemon alone is sufficient; and `migrateOnSurfaceStart` gives a * surface the same entry point, so a machine whose daemon has not run yet still * converges. * * The order is the whole design, and it is the same order in every case: * * 1. Read the value from the surface store. * 2. Write it to the daemon store. * 3. Read it BACK from the daemon store and compare it to what was read in * step 1. * 4. Only then remove the surface copy. * * Step 3 is not ceremony. A daemon store that cannot be written (a read-only * home, a full disk, a key mismatch) fails silently enough that steps 1, 2 and * 4 alone would delete the only working copy of a credential and leave the * operator with nothing. If the read-back does not match, the source is left * exactly where it was and the entry is reported as `verification-failed`, * the credential still works, from the tier it was already in, and the next * start tries again. * * Idempotent by construction: * - A key already in the daemon store with the SAME value: the surface copy * is redundant, so it is removed and the entry reports `already-migrated`. * - A key already in the daemon store with a DIFFERENT value: the daemon's * own copy WINS and is never overwritten. It is the one the daemon has been * running with; a stale surface copy of a rotated credential must not * silently roll it back. Reported as `daemon-copy-kept`, with the surface * copy left alone rather than destroyed, so nothing is lost either way. * - Nothing to do: the run reports zero moves and touches no file. * * Values never appear in a result, a log line or an error. Every field here is * a key name, a tier name or an outcome. */ import type { SecretRecord, SecretScope, SecretStorageMedium } from './secrets.js'; /** What happened to one credential. */ export type CredentialMigrationOutcome = /** Copied up, verified readable in the daemon tier, surface copy removed. */ 'migrated' /** The daemon tier already held the same value; the redundant copy was removed. */ | 'already-migrated' /** The daemon tier held a DIFFERENT value. Its copy wins; nothing was changed. */ | 'daemon-copy-kept' /** The daemon copy did not read back. The source was left in place, untouched. */ | 'verification-failed' /** The daemon write itself threw. The source was left in place, untouched. */ | 'write-failed'; /** One credential's migration result. Key names and tiers only, never a value. */ export interface CredentialMigrationEntry { readonly key: string; readonly fromScope: SecretScope; readonly outcome: CredentialMigrationOutcome; /** Populated for the two failure outcomes. Never contains a value. */ readonly detail?: string | undefined; } export interface CredentialMigrationReport { readonly entries: readonly CredentialMigrationEntry[]; readonly migrated: number; readonly failed: number; /** True when nothing needed doing, the common case after the first run. */ readonly noop: boolean; } /** * The slice of SecretsManager this needs. Narrow on purpose: the migration is * exercised against a fake in tests, and a narrow port is also the honest * statement of what it is allowed to do, read, write, delete, list. It cannot * reach the encryption keys or the store paths. */ export interface MigratableSecretStore { /** * Every credential a migration could move, across EVERY surface's silo. * * Not `listDetailed`, which walks only the surface this manager is rooted at. * That is the right question for resolution and the wrong one here: the * owner's Telegram token sat in the agent's store while the daemon * enumerated only its own, so the credential was one directory away and * invisible to the only code that could lift it. */ listDetailedForMigration(): Promise; get(key: string): Promise; set(key: string, value: string, options?: { scope?: SecretScope; medium?: SecretStorageMedium; }): Promise; /** * Read one tier, or one exact store FILE when `storePath` is given. * * The file form is required because two surfaces both report scope `user`: * `~/.goodvibes/agent/secrets.enc` and `~/.goodvibes/tui/secrets.enc` are the * same tier and different files, so a scope-addressed read cannot say which * copy it got, and the read-back verification would be comparing against an * arbitrary one. */ getFromScope(key: string, scope: SecretScope, storePath?: string): Promise; /** * Remove ONE tier's copy. * * Deliberately NOT `delete`. `delete` is the revoke verb: for a daemon-needed * key it discards the caller's scope and sweeps every tier, which is right * for a revoke and catastrophic here, every key this module touches is * daemon-needed by definition, so `delete(key, { scope: source })` destroyed * the daemon copy that had just been written and verified, while the report * said `migrated: 1, failed: 0`. The port names the narrow operation so the * wrong one cannot be reached from here at all. */ deleteFromScope(key: string, scope: SecretScope, storePath?: string): Promise; } /** * Lift every stranded daemon-needed credential into the daemon tier. * * Safe to call on every start of every product. The common case after the * first run is a single `listDetailed()` and no writes at all. */ export declare function migrateDaemonNeededCredentials(store: MigratableSecretStore): Promise; /** * A durable record of what this migration did, written beside the daemon's own * state. * * The owner authorized this migration to run against their live tree, and * part of that authorization is that they never have to guess whether it * ran. A log line scrolls; this does not. It answers "did it run, when, and * what moved" from disk, months later, and it carries key NAMES, tiers and * outcomes, never a value. * * Rewritten on every run that changed something, so it always describes the * latest move rather than accumulating a history nobody reaps. A run that * moved nothing leaves the previous receipt alone: overwriting it with "nothing * to do" would destroy the record of the run that mattered. */ export interface CredentialMigrationReceipt { readonly version: 1; readonly at: string; readonly migrated: number; readonly failed: number; readonly summary: string; readonly entries: readonly CredentialMigrationEntry[]; } /** Build the receipt for a run. Returns null when there is nothing to record. */ export declare function buildCredentialMigrationReceipt(report: CredentialMigrationReport, now?: Date): CredentialMigrationReceipt | null; /** A one-line, safe-to-display summary. Never contains a value. */ export declare function describeCredentialMigration(report: CredentialMigrationReport): string; /** * The surface-side entry point. * * Identical work, named for where it is called from. A surface running this * lifts credentials into the daemon tier before the daemon has ever started, * which is the case a fresh install hits: setup happens in a client, and the * daemon reads the result later. * * Safe to call on every start of every product, after the first run it is one * enumeration and no writes. */ export declare function migrateOnSurfaceStart(store: MigratableSecretStore): Promise; //# sourceMappingURL=daemon-credential-migration.d.ts.map