imprint-mcp 0.2.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.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local credential storage with pluggable backends.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (set once per process):
|
|
5
|
+
* 1. KeyringBackend — `@napi-rs/keyring` against the OS keychain.
|
|
6
|
+
* Used when available; transparent (no passphrase) and OS-secured.
|
|
7
|
+
* 2. EncryptedFileBackend — libsodium secretbox + argon2id over a single
|
|
8
|
+
* JSON file. Passphrase from $IMPRINT_PASSPHRASE or interactive prompt;
|
|
9
|
+
* cached in process. Used in headless contexts (e.g. Linux container
|
|
10
|
+
* without a desktop session) where the keyring isn't available.
|
|
11
|
+
* 3. Legacy JSON read-only — old `~/.config/imprint/credentials/<site>.json`
|
|
12
|
+
* files are surfaced via `loadLegacyStore` for migration only; we never
|
|
13
|
+
* write back to them.
|
|
14
|
+
*
|
|
15
|
+
* Manifest (non-secret) lives at `~/.config/imprint/manifests/<site>.json`
|
|
16
|
+
* and lists which secrets exist for which site. The manifest is what tells
|
|
17
|
+
* a downstream agent (OpenClaw/Hermes) which credentials it needs to ask
|
|
18
|
+
* for when consuming a shared skill.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from 'node:fs';
|
|
28
|
+
import { dirname, join as pathJoin } from 'node:path';
|
|
29
|
+
import { argon2id } from '@noble/hashes/argon2.js';
|
|
30
|
+
import envPaths from 'env-paths';
|
|
31
|
+
|
|
32
|
+
const PATHS = envPaths('imprint', { suffix: '' });
|
|
33
|
+
const SERVICE_NAME = 'imprint';
|
|
34
|
+
|
|
35
|
+
/** What kind of value a credential is. Only used for the manifest UI. */
|
|
36
|
+
export type CredentialKind = 'username' | 'password' | 'email' | 'token' | 'opaque';
|
|
37
|
+
|
|
38
|
+
export interface ManifestEntry {
|
|
39
|
+
name: string;
|
|
40
|
+
kind: CredentialKind;
|
|
41
|
+
/** Optional human description (printed by `imprint credential list`). */
|
|
42
|
+
description?: string;
|
|
43
|
+
recordedAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface SiteManifest {
|
|
47
|
+
site: string;
|
|
48
|
+
secrets: ManifestEntry[];
|
|
49
|
+
cookies?: Array<{ name: string; value?: never }>; // value is never persisted in the manifest
|
|
50
|
+
storage?: Array<{ origin: string; kind: StorageRecord['kind']; key: string; value?: never }>;
|
|
51
|
+
updatedAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CookieRecord {
|
|
55
|
+
name: string;
|
|
56
|
+
value: string;
|
|
57
|
+
domain: string;
|
|
58
|
+
path: string;
|
|
59
|
+
expires?: number;
|
|
60
|
+
httpOnly?: boolean;
|
|
61
|
+
secure?: boolean;
|
|
62
|
+
sameSite?: string;
|
|
63
|
+
hostOnly?: boolean;
|
|
64
|
+
creationIndex?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StorageRecord {
|
|
68
|
+
origin: string;
|
|
69
|
+
kind: 'localStorage' | 'sessionStorage';
|
|
70
|
+
key: string;
|
|
71
|
+
value: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Backend abstraction. Implementations may be sync or async; we always
|
|
75
|
+
* await to keep the call sites uniform. */
|
|
76
|
+
export interface CredentialBackend {
|
|
77
|
+
readonly id: 'keyring' | 'encrypted-file' | 'legacy-json';
|
|
78
|
+
getSecret(site: string, name: string): Promise<string | null>;
|
|
79
|
+
setSecret(site: string, name: string, value: string): Promise<void>;
|
|
80
|
+
deleteSecret(site: string, name: string): Promise<void>;
|
|
81
|
+
listSecrets(site: string): Promise<string[]>;
|
|
82
|
+
/** Cookies are bulk-replaced rather than per-name because a login flow
|
|
83
|
+
* produces a fresh cookie set as one unit. */
|
|
84
|
+
getCookies(site: string): Promise<CookieRecord[]>;
|
|
85
|
+
setCookies(site: string, cookies: CookieRecord[]): Promise<void>;
|
|
86
|
+
getStorage?(site: string): Promise<StorageRecord[]>;
|
|
87
|
+
setStorage?(site: string, storage: StorageRecord[]): Promise<void>;
|
|
88
|
+
/** Best-effort listing of every site this backend has data for. Used by
|
|
89
|
+
* `imprint credential list` (no <site> argument) and migration. */
|
|
90
|
+
listSites(): Promise<string[]>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── KeyringBackend (primary) ──────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
class KeyringBackend implements CredentialBackend {
|
|
96
|
+
readonly id = 'keyring' as const;
|
|
97
|
+
// Dynamically loaded; keep the module reference so tests can swap it.
|
|
98
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
|
|
99
|
+
private Entry: any;
|
|
100
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
|
|
101
|
+
private findCredentials: any;
|
|
102
|
+
|
|
103
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
|
|
104
|
+
constructor(mod: { Entry: any; findCredentials: any }) {
|
|
105
|
+
this.Entry = mod.Entry;
|
|
106
|
+
this.findCredentials = mod.findCredentials;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private accountFor(site: string, name: string): string {
|
|
110
|
+
return `${site}::${name}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private cookieAccount(site: string): string {
|
|
114
|
+
return `${site}::__cookies__`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private storageAccount(site: string): string {
|
|
118
|
+
return `${site}::__storage__`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getSecret(site: string, name: string): Promise<string | null> {
|
|
122
|
+
const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
|
|
123
|
+
try {
|
|
124
|
+
const v = entry.getPassword();
|
|
125
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async setSecret(site: string, name: string, value: string): Promise<void> {
|
|
132
|
+
const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
|
|
133
|
+
entry.setPassword(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async deleteSecret(site: string, name: string): Promise<void> {
|
|
137
|
+
const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
|
|
138
|
+
try {
|
|
139
|
+
entry.deletePassword();
|
|
140
|
+
} catch {
|
|
141
|
+
// Already absent — fine.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async listSecrets(site: string): Promise<string[]> {
|
|
146
|
+
// findCredentials returns every credential under our service; we filter
|
|
147
|
+
// by site prefix.
|
|
148
|
+
const all = this.findCredentials(SERVICE_NAME) as Array<{ account: string }>;
|
|
149
|
+
const prefix = `${site}::`;
|
|
150
|
+
const cookieAcct = this.cookieAccount(site);
|
|
151
|
+
const storageAcct = this.storageAccount(site);
|
|
152
|
+
return all
|
|
153
|
+
.map((c) => c.account)
|
|
154
|
+
.filter((acct) => acct.startsWith(prefix) && acct !== cookieAcct && acct !== storageAcct)
|
|
155
|
+
.map((acct) => acct.slice(prefix.length));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async getCookies(site: string): Promise<CookieRecord[]> {
|
|
159
|
+
const entry = new this.Entry(SERVICE_NAME, this.cookieAccount(site));
|
|
160
|
+
try {
|
|
161
|
+
const v = entry.getPassword();
|
|
162
|
+
if (typeof v !== 'string' || v.length === 0) return [];
|
|
163
|
+
return JSON.parse(v) as CookieRecord[];
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async setCookies(site: string, cookies: CookieRecord[]): Promise<void> {
|
|
170
|
+
const entry = new this.Entry(SERVICE_NAME, this.cookieAccount(site));
|
|
171
|
+
if (cookies.length === 0) {
|
|
172
|
+
try {
|
|
173
|
+
entry.deletePassword();
|
|
174
|
+
} catch {
|
|
175
|
+
/* ignore */
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
entry.setPassword(JSON.stringify(cookies));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async getStorage(site: string): Promise<StorageRecord[]> {
|
|
183
|
+
const entry = new this.Entry(SERVICE_NAME, this.storageAccount(site));
|
|
184
|
+
try {
|
|
185
|
+
const v = entry.getPassword();
|
|
186
|
+
if (typeof v !== 'string' || v.length === 0) return [];
|
|
187
|
+
return JSON.parse(v) as StorageRecord[];
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async setStorage(site: string, storage: StorageRecord[]): Promise<void> {
|
|
194
|
+
const entry = new this.Entry(SERVICE_NAME, this.storageAccount(site));
|
|
195
|
+
if (storage.length === 0) {
|
|
196
|
+
try {
|
|
197
|
+
entry.deletePassword();
|
|
198
|
+
} catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
entry.setPassword(JSON.stringify(storage));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async listSites(): Promise<string[]> {
|
|
207
|
+
const all = this.findCredentials(SERVICE_NAME) as Array<{ account: string }>;
|
|
208
|
+
const sites = new Set<string>();
|
|
209
|
+
for (const c of all) {
|
|
210
|
+
const idx = c.account.indexOf('::');
|
|
211
|
+
if (idx > 0) sites.add(c.account.slice(0, idx));
|
|
212
|
+
}
|
|
213
|
+
return Array.from(sites).sort();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── EncryptedFileBackend (fallback) ───────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
interface EncryptedFileShape {
|
|
220
|
+
/** Map of site → { secrets: { name → value }, cookies: CookieRecord[] }. */
|
|
221
|
+
sites: Record<
|
|
222
|
+
string,
|
|
223
|
+
{ secrets: Record<string, string>; cookies: CookieRecord[]; storage?: StorageRecord[] }
|
|
224
|
+
>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
class EncryptedFileBackend implements CredentialBackend {
|
|
228
|
+
readonly id = 'encrypted-file' as const;
|
|
229
|
+
private filePath: string;
|
|
230
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic libsodium ref
|
|
231
|
+
private sodium: any;
|
|
232
|
+
private cachedKey: Uint8Array | null = null;
|
|
233
|
+
/** Cached decrypted shape — flushed back to disk on every mutation. */
|
|
234
|
+
private cachedData: EncryptedFileShape | null = null;
|
|
235
|
+
private passphraseProvider: () => Promise<string>;
|
|
236
|
+
|
|
237
|
+
constructor(opts: {
|
|
238
|
+
filePath: string;
|
|
239
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic libsodium ref
|
|
240
|
+
sodium: any;
|
|
241
|
+
passphraseProvider: () => Promise<string>;
|
|
242
|
+
}) {
|
|
243
|
+
this.filePath = opts.filePath;
|
|
244
|
+
this.sodium = opts.sodium;
|
|
245
|
+
this.passphraseProvider = opts.passphraseProvider;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async ensureKeyAndData(opts?: { forWrite?: boolean }): Promise<void> {
|
|
249
|
+
if (this.cachedKey && this.cachedData) return;
|
|
250
|
+
const sodium = this.sodium;
|
|
251
|
+
|
|
252
|
+
if (!existsSync(this.filePath)) {
|
|
253
|
+
// No store yet. For a READ (forWrite !== true), short-circuit with an
|
|
254
|
+
// empty in-memory shape — there cannot be any credentials to return,
|
|
255
|
+
// and prompting for a passphrase here would hang non-interactive
|
|
256
|
+
// callers (CI, MCP server startup, cron). The store is materialised
|
|
257
|
+
// on first WRITE, when we have a real value to protect.
|
|
258
|
+
if (!opts?.forWrite) {
|
|
259
|
+
this.cachedData = { sites: {} };
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const passphrase = await this.passphraseProvider();
|
|
263
|
+
const salt = sodium.randombytes_buf(16);
|
|
264
|
+
this.cachedKey = deriveKey(passphrase, salt);
|
|
265
|
+
this.cachedData = { sites: {} };
|
|
266
|
+
this.persistWithSalt(salt);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const raw = readFileSync(this.filePath, 'utf8');
|
|
271
|
+
let parsed: { saltB64: string; nonceB64: string; ctB64: string };
|
|
272
|
+
try {
|
|
273
|
+
parsed = JSON.parse(raw);
|
|
274
|
+
} catch {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${this.filePath} is corrupted (not JSON). Delete it to start fresh, or restore from a credential bundle.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const salt = b64decode(parsed.saltB64);
|
|
280
|
+
const nonce = b64decode(parsed.nonceB64);
|
|
281
|
+
const ct = b64decode(parsed.ctB64);
|
|
282
|
+
|
|
283
|
+
// Try cached passphrase first if the user has set $IMPRINT_PASSPHRASE
|
|
284
|
+
// and re-entered process.
|
|
285
|
+
const passphrase = await this.passphraseProvider();
|
|
286
|
+
const key = deriveKey(passphrase, salt);
|
|
287
|
+
let plain: Uint8Array;
|
|
288
|
+
try {
|
|
289
|
+
plain = sodium.crypto_secretbox_open_easy(ct, nonce, key);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Wrong passphrase for ${this.filePath}.\n→ set $IMPRINT_PASSPHRASE or re-run with the correct passphrase. To start over, delete the file (you'll lose stored credentials).`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const text = new TextDecoder().decode(plain);
|
|
296
|
+
this.cachedKey = key;
|
|
297
|
+
this.cachedData = JSON.parse(text) as EncryptedFileShape;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private persistWithSalt(salt: Uint8Array): void {
|
|
301
|
+
if (!this.cachedData || !this.cachedKey) return;
|
|
302
|
+
const sodium = this.sodium;
|
|
303
|
+
const text = JSON.stringify(this.cachedData);
|
|
304
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
305
|
+
const ct = sodium.crypto_secretbox_easy(new TextEncoder().encode(text), nonce, this.cachedKey);
|
|
306
|
+
const wire = {
|
|
307
|
+
version: 1,
|
|
308
|
+
saltB64: b64encode(salt),
|
|
309
|
+
nonceB64: b64encode(nonce),
|
|
310
|
+
ctB64: b64encode(ct),
|
|
311
|
+
};
|
|
312
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
313
|
+
writeFileSync(this.filePath, JSON.stringify(wire), 'utf8');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Persist using the SAME salt the file was loaded with. */
|
|
317
|
+
private persist(): void {
|
|
318
|
+
if (!this.cachedData) return;
|
|
319
|
+
const raw = readFileSync(this.filePath, 'utf8');
|
|
320
|
+
const parsed = JSON.parse(raw) as { saltB64: string };
|
|
321
|
+
this.persistWithSalt(b64decode(parsed.saltB64));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private siteData(site: string): {
|
|
325
|
+
secrets: Record<string, string>;
|
|
326
|
+
cookies: CookieRecord[];
|
|
327
|
+
storage?: StorageRecord[];
|
|
328
|
+
} {
|
|
329
|
+
if (!this.cachedData) throw new Error('cachedData not initialized');
|
|
330
|
+
let bucket = this.cachedData.sites[site];
|
|
331
|
+
if (!bucket) {
|
|
332
|
+
bucket = { secrets: {}, cookies: [], storage: [] };
|
|
333
|
+
this.cachedData.sites[site] = bucket;
|
|
334
|
+
}
|
|
335
|
+
return bucket;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getSecret(site: string, name: string): Promise<string | null> {
|
|
339
|
+
await this.ensureKeyAndData();
|
|
340
|
+
const bucket = this.cachedData?.sites[site];
|
|
341
|
+
return bucket?.secrets[name] ?? null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async setSecret(site: string, name: string, value: string): Promise<void> {
|
|
345
|
+
await this.ensureKeyAndData({ forWrite: true });
|
|
346
|
+
this.siteData(site).secrets[name] = value;
|
|
347
|
+
this.persist();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async deleteSecret(site: string, name: string): Promise<void> {
|
|
351
|
+
await this.ensureKeyAndData({ forWrite: true });
|
|
352
|
+
const bucket = this.cachedData?.sites[site];
|
|
353
|
+
if (bucket && name in bucket.secrets) {
|
|
354
|
+
delete bucket.secrets[name];
|
|
355
|
+
this.persist();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async listSecrets(site: string): Promise<string[]> {
|
|
360
|
+
await this.ensureKeyAndData();
|
|
361
|
+
const bucket = this.cachedData?.sites[site];
|
|
362
|
+
return bucket ? Object.keys(bucket.secrets) : [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async getCookies(site: string): Promise<CookieRecord[]> {
|
|
366
|
+
await this.ensureKeyAndData();
|
|
367
|
+
return this.cachedData?.sites[site]?.cookies ?? [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async setCookies(site: string, cookies: CookieRecord[]): Promise<void> {
|
|
371
|
+
await this.ensureKeyAndData({ forWrite: true });
|
|
372
|
+
this.siteData(site).cookies = cookies;
|
|
373
|
+
this.persist();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async getStorage(site: string): Promise<StorageRecord[]> {
|
|
377
|
+
await this.ensureKeyAndData();
|
|
378
|
+
return this.cachedData?.sites[site]?.storage ?? [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async setStorage(site: string, storage: StorageRecord[]): Promise<void> {
|
|
382
|
+
await this.ensureKeyAndData({ forWrite: true });
|
|
383
|
+
this.siteData(site).storage = storage;
|
|
384
|
+
this.persist();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async listSites(): Promise<string[]> {
|
|
388
|
+
await this.ensureKeyAndData();
|
|
389
|
+
return Object.keys(this.cachedData?.sites ?? {}).sort();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ─── KDF + base64 helpers ──────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
const KDF_OPTS = {
|
|
396
|
+
/** Iterations. Argon2 RFC 9106 minimum recommended interactive. */
|
|
397
|
+
t: 3,
|
|
398
|
+
/** Memory in KiB — 64 MiB. */
|
|
399
|
+
m: 64 * 1024,
|
|
400
|
+
/** Parallelism. */
|
|
401
|
+
p: 4,
|
|
402
|
+
/** Output 32 bytes for crypto_secretbox key. */
|
|
403
|
+
dkLen: 32,
|
|
404
|
+
} as const;
|
|
405
|
+
|
|
406
|
+
function deriveKey(passphrase: string, salt: Uint8Array): Uint8Array {
|
|
407
|
+
return argon2id(new TextEncoder().encode(passphrase), salt, KDF_OPTS);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function b64encode(bytes: Uint8Array): string {
|
|
411
|
+
return Buffer.from(bytes).toString('base64');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function b64decode(s: string): Uint8Array {
|
|
415
|
+
return new Uint8Array(Buffer.from(s, 'base64'));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── Manifest (non-secret, JSON file) ──────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function manifestPath(site: string): string {
|
|
421
|
+
return pathJoin(PATHS.config, 'manifests', `${site}.json`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function readSiteManifest(site: string): SiteManifest | null {
|
|
425
|
+
const p = manifestPath(site);
|
|
426
|
+
if (!existsSync(p)) return null;
|
|
427
|
+
try {
|
|
428
|
+
return JSON.parse(readFileSync(p, 'utf8')) as SiteManifest;
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function writeSiteManifest(manifest: SiteManifest): void {
|
|
435
|
+
const p = manifestPath(manifest.site);
|
|
436
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
437
|
+
writeFileSync(p, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function deleteSiteManifest(site: string): void {
|
|
441
|
+
const p = manifestPath(site);
|
|
442
|
+
if (existsSync(p)) unlinkSync(p);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function listManifestSites(): string[] {
|
|
446
|
+
const dir = pathJoin(PATHS.config, 'manifests');
|
|
447
|
+
if (!existsSync(dir)) return [];
|
|
448
|
+
return readdirSync(dir)
|
|
449
|
+
.filter((f) => f.endsWith('.json'))
|
|
450
|
+
.map((f) => f.slice(0, -'.json'.length))
|
|
451
|
+
.sort();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Adds or updates a single manifest entry, persisting the file. */
|
|
455
|
+
export function upsertManifestEntry(
|
|
456
|
+
site: string,
|
|
457
|
+
entry: Omit<ManifestEntry, 'recordedAt'> & { recordedAt?: string },
|
|
458
|
+
): SiteManifest {
|
|
459
|
+
const existing = readSiteManifest(site);
|
|
460
|
+
const recordedAt = entry.recordedAt ?? new Date().toISOString();
|
|
461
|
+
const next: SiteManifest = existing
|
|
462
|
+
? {
|
|
463
|
+
site,
|
|
464
|
+
secrets: existing.secrets
|
|
465
|
+
.filter((s) => s.name !== entry.name)
|
|
466
|
+
.concat({
|
|
467
|
+
name: entry.name,
|
|
468
|
+
kind: entry.kind,
|
|
469
|
+
description: entry.description,
|
|
470
|
+
recordedAt,
|
|
471
|
+
}),
|
|
472
|
+
cookies: existing.cookies,
|
|
473
|
+
storage: existing.storage,
|
|
474
|
+
updatedAt: new Date().toISOString(),
|
|
475
|
+
}
|
|
476
|
+
: {
|
|
477
|
+
site,
|
|
478
|
+
secrets: [
|
|
479
|
+
{
|
|
480
|
+
name: entry.name,
|
|
481
|
+
kind: entry.kind,
|
|
482
|
+
description: entry.description,
|
|
483
|
+
recordedAt,
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
updatedAt: new Date().toISOString(),
|
|
487
|
+
};
|
|
488
|
+
writeSiteManifest(next);
|
|
489
|
+
return next;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function removeManifestEntry(site: string, name: string): SiteManifest | null {
|
|
493
|
+
const existing = readSiteManifest(site);
|
|
494
|
+
if (!existing) return null;
|
|
495
|
+
const next: SiteManifest = {
|
|
496
|
+
site,
|
|
497
|
+
secrets: existing.secrets.filter((s) => s.name !== name),
|
|
498
|
+
cookies: existing.cookies,
|
|
499
|
+
storage: existing.storage,
|
|
500
|
+
updatedAt: new Date().toISOString(),
|
|
501
|
+
};
|
|
502
|
+
if (next.secrets.length === 0) {
|
|
503
|
+
deleteSiteManifest(site);
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
writeSiteManifest(next);
|
|
507
|
+
return next;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function setManifestStorageKeys(
|
|
511
|
+
site: string,
|
|
512
|
+
storage: Array<{ origin: string; kind: StorageRecord['kind']; key: string }>,
|
|
513
|
+
): SiteManifest {
|
|
514
|
+
const existing = readSiteManifest(site);
|
|
515
|
+
const next: SiteManifest = existing
|
|
516
|
+
? {
|
|
517
|
+
...existing,
|
|
518
|
+
storage: storage.map((s) => ({ origin: s.origin, kind: s.kind, key: s.key })),
|
|
519
|
+
updatedAt: new Date().toISOString(),
|
|
520
|
+
}
|
|
521
|
+
: {
|
|
522
|
+
site,
|
|
523
|
+
secrets: [],
|
|
524
|
+
storage: storage.map((s) => ({ origin: s.origin, kind: s.kind, key: s.key })),
|
|
525
|
+
updatedAt: new Date().toISOString(),
|
|
526
|
+
};
|
|
527
|
+
writeSiteManifest(next);
|
|
528
|
+
return next;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ─── Backend resolution ────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
let cachedBackend: CredentialBackend | null = null;
|
|
534
|
+
let cachedBackendOverride: CredentialBackend | null = null;
|
|
535
|
+
|
|
536
|
+
/** For tests: replace the backend used by getCredentialBackend. */
|
|
537
|
+
export function setBackendOverride(backend: CredentialBackend | null): void {
|
|
538
|
+
cachedBackendOverride = backend;
|
|
539
|
+
cachedBackend = null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Reset the resolved backend so the next call re-resolves. Tests use this
|
|
543
|
+
* to recover from a swapped-in keyring module. */
|
|
544
|
+
export function resetBackendCache(): void {
|
|
545
|
+
cachedBackend = null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function defaultEncryptedFilePath(): string {
|
|
549
|
+
return pathJoin(PATHS.config, 'secrets.enc');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Try loading @napi-rs/keyring; return null if it's unavailable in this
|
|
553
|
+
* environment (no Secret Service on Linux without a desktop session,
|
|
554
|
+
* locked keychain on first use, etc.). */
|
|
555
|
+
function tryLoadKeyring(): KeyringBackend | null {
|
|
556
|
+
try {
|
|
557
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic require
|
|
558
|
+
const mod = require('@napi-rs/keyring') as any;
|
|
559
|
+
if (!mod?.Entry || !mod?.findCredentials) return null;
|
|
560
|
+
// Smoke-test by listing — this throws if the keyring backend isn't
|
|
561
|
+
// actually usable (Linux Alpine without libsecret, etc.).
|
|
562
|
+
try {
|
|
563
|
+
mod.findCredentials(SERVICE_NAME);
|
|
564
|
+
} catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
return new KeyringBackend(mod);
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Resolves the active credential backend. Cached after first call. Not
|
|
574
|
+
* reentrant-safe but every call site awaits before its next read. */
|
|
575
|
+
export async function getCredentialBackend(opts?: {
|
|
576
|
+
passphraseProvider?: () => Promise<string>;
|
|
577
|
+
forceEncryptedFile?: boolean;
|
|
578
|
+
}): Promise<CredentialBackend> {
|
|
579
|
+
if (cachedBackendOverride) return cachedBackendOverride;
|
|
580
|
+
if (cachedBackend) return cachedBackend;
|
|
581
|
+
|
|
582
|
+
const wantEnc = opts?.forceEncryptedFile === true || process.env.IMPRINT_BACKEND === 'file';
|
|
583
|
+
|
|
584
|
+
if (!wantEnc) {
|
|
585
|
+
const kr = tryLoadKeyring();
|
|
586
|
+
if (kr) {
|
|
587
|
+
cachedBackend = kr;
|
|
588
|
+
return kr;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Fall back to encrypted file. We resolve libsodium lazily because it
|
|
593
|
+
// pulls a chunk of WASM and we want the keyring path to stay zero-cost.
|
|
594
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic libsodium ref
|
|
595
|
+
const sodium: any = await import('libsodium-wrappers').then(async (m) => {
|
|
596
|
+
await m.default.ready;
|
|
597
|
+
return m.default;
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const provider = opts?.passphraseProvider ?? defaultPassphraseProvider();
|
|
601
|
+
cachedBackend = new EncryptedFileBackend({
|
|
602
|
+
filePath: defaultEncryptedFilePath(),
|
|
603
|
+
sodium,
|
|
604
|
+
passphraseProvider: provider,
|
|
605
|
+
});
|
|
606
|
+
return cachedBackend;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let cachedPassphrase: string | null = null;
|
|
610
|
+
|
|
611
|
+
function defaultPassphraseProvider(): () => Promise<string> {
|
|
612
|
+
return async () => {
|
|
613
|
+
if (cachedPassphrase !== null) return cachedPassphrase;
|
|
614
|
+
const env = process.env.IMPRINT_PASSPHRASE;
|
|
615
|
+
if (env && env.length > 0) {
|
|
616
|
+
cachedPassphrase = env;
|
|
617
|
+
return env;
|
|
618
|
+
}
|
|
619
|
+
// Lazy-import @clack/prompts so non-interactive callers don't pay for it.
|
|
620
|
+
const p = await import('@clack/prompts');
|
|
621
|
+
const answer = await p.password({
|
|
622
|
+
message: 'Passphrase for the encrypted credential store',
|
|
623
|
+
mask: '*',
|
|
624
|
+
validate: (v) =>
|
|
625
|
+
!v || v.length < 8 ? 'Passphrase must be at least 8 characters.' : undefined,
|
|
626
|
+
});
|
|
627
|
+
if (p.isCancel(answer)) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
'Passphrase prompt cancelled. Set $IMPRINT_PASSPHRASE to avoid the prompt in non-interactive contexts.',
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
cachedPassphrase = answer as string;
|
|
633
|
+
return cachedPassphrase;
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ─── Legacy JSON store (read-only fallback) ────────────────────────────────
|
|
638
|
+
|
|
639
|
+
interface LegacyStoreShape {
|
|
640
|
+
site: string;
|
|
641
|
+
cookies: CookieRecord[];
|
|
642
|
+
values: Record<string, string>;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function legacyStorePath(site: string): string {
|
|
646
|
+
return pathJoin(PATHS.config, 'credentials', `${site}.json`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export function readLegacyStore(site: string): LegacyStoreShape | null {
|
|
650
|
+
const p = legacyStorePath(site);
|
|
651
|
+
if (!existsSync(p)) return null;
|
|
652
|
+
try {
|
|
653
|
+
return JSON.parse(readFileSync(p, 'utf8')) as LegacyStoreShape;
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function listLegacyStoreSites(): string[] {
|
|
660
|
+
const dir = pathJoin(PATHS.config, 'credentials');
|
|
661
|
+
if (!existsSync(dir)) return [];
|
|
662
|
+
return readdirSync(dir)
|
|
663
|
+
.filter((f) => f.endsWith('.json') && !f.endsWith('.migrated'))
|
|
664
|
+
.map((f) => f.slice(0, -'.json'.length))
|
|
665
|
+
.sort();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function markLegacyStoreMigrated(site: string): void {
|
|
669
|
+
const src = legacyStorePath(site);
|
|
670
|
+
if (!existsSync(src)) return;
|
|
671
|
+
const dest = `${src}.migrated`;
|
|
672
|
+
writeFileSync(dest, readFileSync(src, 'utf8'), 'utf8');
|
|
673
|
+
unlinkSync(src);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ─── High-level convenience API ────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
/** A site's full credential view: secrets values + cookies. Used by runtime. */
|
|
679
|
+
interface SiteCredentialView {
|
|
680
|
+
site: string;
|
|
681
|
+
cookies: CookieRecord[];
|
|
682
|
+
values: Record<string, string>;
|
|
683
|
+
storage: StorageRecord[];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Loads everything we know about a site, falling back through the backends.
|
|
687
|
+
* Used at request time by runtime.executeWorkflow. */
|
|
688
|
+
export async function loadSiteCredentials(site: string): Promise<SiteCredentialView> {
|
|
689
|
+
const backend = await getCredentialBackend();
|
|
690
|
+
const names = await backend.listSecrets(site);
|
|
691
|
+
const values: Record<string, string> = {};
|
|
692
|
+
for (const n of names) {
|
|
693
|
+
const v = await backend.getSecret(site, n);
|
|
694
|
+
if (v !== null) values[n] = v;
|
|
695
|
+
}
|
|
696
|
+
const cookies = await backend.getCookies(site);
|
|
697
|
+
const storage = (await backend.getStorage?.(site)) ?? [];
|
|
698
|
+
|
|
699
|
+
// Fall through to legacy if the backend has nothing.
|
|
700
|
+
if (Object.keys(values).length === 0 && cookies.length === 0 && storage.length === 0) {
|
|
701
|
+
const legacy = readLegacyStore(site);
|
|
702
|
+
if (legacy) {
|
|
703
|
+
return { site, cookies: legacy.cookies, values: legacy.values, storage: [] };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return { site, cookies, values, storage };
|
|
707
|
+
}
|