tr-plugin-auth 0.0.0-stage → 1.0.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/README.md +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +266 -0
- package/src/access-keys.mjs +225 -0
- package/src/agent-dir.mjs +3 -0
- package/src/browser-session.mjs +240 -0
- package/src/cli-secret.mjs +1687 -0
- package/src/config.mjs +4 -0
- package/src/configuration.mjs +19 -0
- package/src/credential-preservation.mjs +28 -0
- package/src/crypto.mjs +195 -0
- package/src/env-import.mjs +502 -0
- package/src/fact-seeker.mjs +3 -0
- package/src/frontmatter.mjs +3 -0
- package/src/google-oauth.mjs +139 -0
- package/src/host-runtime.mjs +7 -0
- package/src/leader-election.mjs +9 -0
- package/src/logger.mjs +3 -0
- package/src/mesh-auth.mjs +7 -0
- package/src/mesh-identity.mjs +16 -0
- package/src/mesh-secrets.mjs +146 -0
- package/src/mesh.mjs +23 -0
- package/src/metadata-state.mjs +17 -0
- package/src/project-id.mjs +3 -0
- package/src/provider-account-sync.mjs +1145 -0
- package/src/provider-catalog.mjs +515 -0
- package/src/provider-rotation-recipes.mjs +299 -0
- package/src/research-gate.mjs +3 -0
- package/src/research-queue.mjs +3 -0
- package/src/rotation-capability.mjs +189 -0
- package/src/routes/auth.mjs +207 -0
- package/src/routes/keys.mjs +61 -0
- package/src/routes/secrets-runtime.mjs +5 -0
- package/src/routes/secrets.mjs +747 -0
- package/src/routes/webauthn.mjs +163 -0
- package/src/runtime-credentials.mjs +19 -0
- package/src/runtime.mjs +3 -0
- package/src/secret-integration-research.mjs +340 -0
- package/src/secrets-env-diff.mjs +132 -0
- package/src/secrets-env-export.mjs +322 -0
- package/src/secrets-gateway.mjs +253 -0
- package/src/secrets-keychain.mjs +83 -0
- package/src/secrets-rekey.mjs +366 -0
- package/src/secrets-remote-deploy.mjs +250 -0
- package/src/secrets-rotate.mjs +437 -0
- package/src/secrets-store.mjs +756 -0
- package/src/secrets-sync.mjs +81 -0
- package/src/secure-file.mjs +71 -0
- package/src/server-auth.mjs +500 -0
- package/src/session-credentials.mjs +71 -0
- package/src/ssss-store.mjs +13 -0
- package/src/stdin.mjs +31 -0
- package/src/throttled-fetch.mjs +3 -0
- package/src/vault.mjs +3 -0
- package/src/vfs-documents.mjs +3 -0
- package/src/webauthn-store.mjs +265 -0
- package/ui/DESIGN.md +19 -0
- package/ui/card.js +274 -0
- package/ui/panel.js +338 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
/** Optional secret administration; encrypted storage lives in the portable gateway. */
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { authLedger, appendAuthMetadata } from './metadata-state.mjs';
|
|
8
|
+
import { resolveSecretsPath, resolveAuditPath, resolveUsagePath, secretsPassword, readTrEnvFilePassword, isPlainJsonStore, loadSecretsSync, saveSecretsSync, loadSecrets, saveSecrets, validateSecretsBuffer, replaceSecretsBufferAtomic, ensureMeta, appendAudit, META_KEY } from './secrets-gateway.mjs';
|
|
9
|
+
export * from './secrets-gateway.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse repos field. A credential may be bound to zero (developer/tooling),
|
|
13
|
+
* one, or several repos; uniqueness per repo is not required.
|
|
14
|
+
*
|
|
15
|
+
* @param {string|string[]|null|undefined} v
|
|
16
|
+
* @param {object} [_opts] - accepted for compatibility; there is no limit
|
|
17
|
+
* @returns {string[]}
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeReposBinding(v, _opts = {}) {
|
|
20
|
+
let list = [];
|
|
21
|
+
if (Array.isArray(v)) {
|
|
22
|
+
list = [...new Set(v.map(String).map((s) => s.trim()).filter(Boolean))];
|
|
23
|
+
} else if (typeof v === 'string') {
|
|
24
|
+
list = [
|
|
25
|
+
...new Set(
|
|
26
|
+
v
|
|
27
|
+
.split(/[,;\s]+/)
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean),
|
|
30
|
+
),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
return list;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Multi-repo binding is allowed, so this is never a violation. Kept because
|
|
38
|
+
* callers still read the field.
|
|
39
|
+
*/
|
|
40
|
+
export function isMultiRepoViolation(_meta = {}) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Lowercase, de-duplicated tag list from an array or comma/space separated string. */
|
|
45
|
+
export function normalizeTags(v) {
|
|
46
|
+
const raw = Array.isArray(v) ? v : String(v ?? '').split(/[,;\s]+/);
|
|
47
|
+
return [...new Set(raw.map((t) => String(t).trim().toLowerCase()).filter(Boolean))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize / merge secret metadata (never stores values).
|
|
52
|
+
* @param {object} prev
|
|
53
|
+
* @param {object} patch
|
|
54
|
+
*/
|
|
55
|
+
export function mergeSecretMeta(prev = {}, patch = {}) {
|
|
56
|
+
const next = { ...prev };
|
|
57
|
+
const assign = (field, transform) => {
|
|
58
|
+
if (patch[field] !== undefined) {
|
|
59
|
+
next[field] = transform ? transform(patch[field]) : patch[field];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
assign('scope', (v) => (v === 'project' ? 'project' : 'global'));
|
|
63
|
+
assign('provider', (v) => (v ? String(v).toLowerCase() : null));
|
|
64
|
+
assign('label', (v) => (v == null ? null : String(v)));
|
|
65
|
+
// empty = Developer secrets
|
|
66
|
+
assign('repos', (v) => normalizeReposBinding(v));
|
|
67
|
+
assign('tags', normalizeTags);
|
|
68
|
+
assign('subscription_tier', (v) => (v == null || v === '' ? null : String(v)));
|
|
69
|
+
assign('monthly_cost_usd', (v) => (v == null || v === '' ? null : Number(v)));
|
|
70
|
+
assign('monthly_cap_usd', (v) => (v == null || v === '' ? null : Number(v)));
|
|
71
|
+
assign('api_docs_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
72
|
+
assign('console_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
73
|
+
assign('pricing_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
74
|
+
assign('schema_notes', (v) => (v == null || v === '' ? null : String(v)));
|
|
75
|
+
assign('auth_scheme', (v) => (v == null || v === '' ? null : String(v)));
|
|
76
|
+
assign('rotate_every_days', (v) => {
|
|
77
|
+
if (v == null || v === '' || v === false) return null;
|
|
78
|
+
const n = parseInt(v, 10);
|
|
79
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
80
|
+
});
|
|
81
|
+
assign('auto_rotate', (v) => !!v);
|
|
82
|
+
assign('notes', (v) => (v == null || v === '' ? null : String(v)));
|
|
83
|
+
assign('project_path', (v) => (v == null || v === '' ? null : String(v)));
|
|
84
|
+
assign('headscale_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
85
|
+
// Command that prints the headscale server log. Watch mode needs it because
|
|
86
|
+
// headscale exposes no pending-registration API -- a device waiting to be
|
|
87
|
+
// approved is visible only in the log. Kept as config so no host is ever
|
|
88
|
+
// named in code.
|
|
89
|
+
assign('headscale_log_command', (v) => (v == null || v === '' ? null : String(v)));
|
|
90
|
+
// Live provider account / usage / subscription tracking (provider-account-sync)
|
|
91
|
+
assign('tracking_exempt', (v) => !!v);
|
|
92
|
+
assign('tracking_status', (v) => {
|
|
93
|
+
if (v == null || v === '') return null;
|
|
94
|
+
const s = String(v).toLowerCase();
|
|
95
|
+
return ['ok', 'partial', 'error', 'exempt', 'never'].includes(s) ? s : 'error';
|
|
96
|
+
});
|
|
97
|
+
assign('tracking_error', (v) => (v == null || v === '' ? null : String(v)));
|
|
98
|
+
assign('tracking_synced_at', (v) => (v == null || v === '' ? null : String(v)));
|
|
99
|
+
assign('tracking_probe', (v) => (v == null || v === '' ? null : String(v)));
|
|
100
|
+
assign('tracking_account', (v) => (v == null ? null : v));
|
|
101
|
+
assign('tracking_usage', (v) => (v == null ? null : v));
|
|
102
|
+
assign('tracking_subscription', (v) => (v == null ? null : v));
|
|
103
|
+
assign('account_api', (v) => (v == null ? null : !!v));
|
|
104
|
+
assign('usage_api', (v) => (v == null ? null : !!v));
|
|
105
|
+
assign('subscription_api', (v) => (v == null ? null : !!v));
|
|
106
|
+
assign('key_valid', (v) => (v == null ? null : !!v));
|
|
107
|
+
// Operator waiver: intentional shared credential (rare — prefer unique keys per app)
|
|
108
|
+
assign('shared_value_ok', (v) => !!v);
|
|
109
|
+
if (patch.updated_at) next.updated_at = patch.updated_at;
|
|
110
|
+
if (patch.rotated_at) next.rotated_at = patch.rotated_at;
|
|
111
|
+
if (patch.created_at && !next.created_at) next.created_at = patch.created_at;
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute next rotation due ISO date from meta.
|
|
117
|
+
*/
|
|
118
|
+
export function nextRotateDue(meta = {}) {
|
|
119
|
+
if (!meta.rotate_every_days) return null;
|
|
120
|
+
const base = meta.rotated_at || meta.updated_at || meta.created_at;
|
|
121
|
+
if (!base) return null;
|
|
122
|
+
const t = new Date(base).getTime() + meta.rotate_every_days * 86400000;
|
|
123
|
+
if (!Number.isFinite(t)) return null;
|
|
124
|
+
return new Date(t).toISOString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Canonical app label for repo binding (empty = developer/global). */
|
|
128
|
+
export function appLabelForSecret(row = {}) {
|
|
129
|
+
if (row.repo) return String(row.repo);
|
|
130
|
+
if (Array.isArray(row.repos) && row.repos.length === 1) return String(row.repos[0]);
|
|
131
|
+
if (Array.isArray(row.repos) && row.repos.length > 1) return row.repos.join('+');
|
|
132
|
+
return 'developer';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Full value fingerprint (never truncated — used for equality).
|
|
137
|
+
* @param {string} value
|
|
138
|
+
*/
|
|
139
|
+
export function secretValueFingerprint(value) {
|
|
140
|
+
if (value == null || value === '') return null;
|
|
141
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build shared-value groups from rows that already have fingerprint_full + set.
|
|
146
|
+
* Same credential material under multiple key names / apps must be an ERROR so
|
|
147
|
+
* each app can get its own rotated key.
|
|
148
|
+
*
|
|
149
|
+
* @param {Array<object>} rows - secret meta rows (with fingerprint_full)
|
|
150
|
+
* @returns {{ groups: object[], byKey: Map<string, object> }}
|
|
151
|
+
*/
|
|
152
|
+
export function buildSharedValueIndex(rows = []) {
|
|
153
|
+
const byFp = new Map();
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
if (!row?.set || !row.fingerprint_full) continue;
|
|
156
|
+
// Skip pure internal passwords that are intentionally shared (e.g. same JWT across?)
|
|
157
|
+
// No — still flag; operator can mark shared_value_ok.
|
|
158
|
+
if (!byFp.has(row.fingerprint_full)) byFp.set(row.fingerprint_full, []);
|
|
159
|
+
byFp.get(row.fingerprint_full).push(row);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const groups = [];
|
|
163
|
+
const byKey = new Map();
|
|
164
|
+
|
|
165
|
+
for (const [fp, members] of byFp) {
|
|
166
|
+
if (members.length < 2) continue;
|
|
167
|
+
const apps = [...new Set(members.map((m) => appLabelForSecret(m)))].sort();
|
|
168
|
+
const keys = members.map((m) => m.key).sort();
|
|
169
|
+
const providers = [...new Set(members.map((m) => m.provider).filter(Boolean))];
|
|
170
|
+
const multiApp = apps.length > 1;
|
|
171
|
+
const multiKey = keys.length > 1;
|
|
172
|
+
// Error unless every member is operator-waived
|
|
173
|
+
const allOk = members.every((m) => m.shared_value_ok === true);
|
|
174
|
+
// Sharing a value across keys or repos is allowed; it is reported for
|
|
175
|
+
// information only and never counts as unhealthy.
|
|
176
|
+
void allOk;
|
|
177
|
+
const severity = 'info';
|
|
178
|
+
const error = multiApp
|
|
179
|
+
? `Shared value across [${apps.join(', ')}] as keys [${keys.join(', ')}] (allowed).`
|
|
180
|
+
: `Same value stored under ${keys.length} secret names [${keys.join(', ')}] (allowed).`;
|
|
181
|
+
|
|
182
|
+
const group = {
|
|
183
|
+
fingerprint: fp.slice(0, 12),
|
|
184
|
+
fingerprint_full: fp,
|
|
185
|
+
count: members.length,
|
|
186
|
+
apps,
|
|
187
|
+
multi_app: multiApp,
|
|
188
|
+
multi_key: multiKey,
|
|
189
|
+
keys,
|
|
190
|
+
providers,
|
|
191
|
+
severity,
|
|
192
|
+
error,
|
|
193
|
+
members: members.map((m) => ({
|
|
194
|
+
key: m.key,
|
|
195
|
+
repo: m.repo || null,
|
|
196
|
+
repos: m.repos || [],
|
|
197
|
+
app: appLabelForSecret(m),
|
|
198
|
+
provider: m.provider || null,
|
|
199
|
+
masked: m.masked || null,
|
|
200
|
+
shared_value_ok: !!m.shared_value_ok,
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
groups.push(group);
|
|
204
|
+
for (const m of members) {
|
|
205
|
+
byKey.set(m.key, {
|
|
206
|
+
shared_value: true,
|
|
207
|
+
shared_with: members
|
|
208
|
+
.filter((o) => o.key !== m.key)
|
|
209
|
+
.map((o) => ({
|
|
210
|
+
key: o.key,
|
|
211
|
+
app: appLabelForSecret(o),
|
|
212
|
+
repo: o.repo || null,
|
|
213
|
+
provider: o.provider || null,
|
|
214
|
+
})),
|
|
215
|
+
shared_apps: apps,
|
|
216
|
+
shared_value_error: error,
|
|
217
|
+
shared_value_severity: severity,
|
|
218
|
+
shared_fingerprint: fp.slice(0, 12),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
groups.sort((a, b) => b.count - a.count || a.fingerprint.localeCompare(b.fingerprint));
|
|
224
|
+
return { groups, byKey };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* List secret keys with rich metadata only (no values).
|
|
229
|
+
* Annotates shared-value groups (same credential material under multiple keys/apps).
|
|
230
|
+
*/
|
|
231
|
+
export async function listSecretsMeta(brainDir) {
|
|
232
|
+
const { getProvider, providerForKeyName } = await import('./provider-catalog.mjs');
|
|
233
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
234
|
+
const meta = secrets[META_KEY].keys || {};
|
|
235
|
+
const usageByKey = summarizeUsageByKey(brainDir, { days: 30 });
|
|
236
|
+
const keys = Object.keys(secrets).filter((k) => k !== META_KEY);
|
|
237
|
+
const rows = keys.map((key) => {
|
|
238
|
+
const m = meta[key] || {};
|
|
239
|
+
const val = secrets[key];
|
|
240
|
+
const providerId = m.provider || providerForKeyName(key)?.id || null;
|
|
241
|
+
const catalog = providerId ? getProvider(providerId) : providerForKeyName(key);
|
|
242
|
+
const due = nextRotateDue(m);
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
const rotation_overdue = due ? new Date(due).getTime() < now : false;
|
|
245
|
+
const usage = usageByKey.by_key[key] || {
|
|
246
|
+
events: 0,
|
|
247
|
+
cost_usd: 0,
|
|
248
|
+
input_tokens: 0,
|
|
249
|
+
output_tokens: 0,
|
|
250
|
+
};
|
|
251
|
+
const repos = Array.isArray(m.repos) ? m.repos.filter(Boolean) : [];
|
|
252
|
+
const fpFull =
|
|
253
|
+
typeof val === 'string' && val.length ? secretValueFingerprint(val) : null;
|
|
254
|
+
return {
|
|
255
|
+
key,
|
|
256
|
+
set: val !== undefined && val !== null && val !== '',
|
|
257
|
+
length: typeof val === 'string' ? val.length : val != null ? String(val).length : 0,
|
|
258
|
+
fingerprint: fpFull ? fpFull.slice(0, 12) : null,
|
|
259
|
+
fingerprint_full: fpFull,
|
|
260
|
+
masked:
|
|
261
|
+
typeof val === 'string' && val.length > 8
|
|
262
|
+
? `${val.slice(0, 4)}…${val.slice(-4)}`
|
|
263
|
+
: val
|
|
264
|
+
? '••••••••'
|
|
265
|
+
: null,
|
|
266
|
+
scope: m.scope || 'global',
|
|
267
|
+
provider: providerId,
|
|
268
|
+
provider_name: catalog?.name || providerId,
|
|
269
|
+
label: m.label || null,
|
|
270
|
+
repos,
|
|
271
|
+
repo: repos.length === 1 ? repos[0] : null,
|
|
272
|
+
multi_repo_error: false,
|
|
273
|
+
binding_error: null,
|
|
274
|
+
tags: Array.isArray(m.tags) ? m.tags : [],
|
|
275
|
+
project_path: m.project_path || null,
|
|
276
|
+
subscription_tier: m.subscription_tier || null,
|
|
277
|
+
monthly_cost_usd: m.monthly_cost_usd ?? null,
|
|
278
|
+
monthly_cap_usd: m.monthly_cap_usd ?? catalog?.default_monthly_cap_usd ?? null,
|
|
279
|
+
api_docs_url: m.api_docs_url || catalog?.docs_url || null,
|
|
280
|
+
console_url: m.console_url || catalog?.console_url || null,
|
|
281
|
+
pricing_url: m.pricing_url || catalog?.pricing_url || null,
|
|
282
|
+
schema: catalog?.schema || null,
|
|
283
|
+
schema_notes: m.schema_notes || catalog?.schema?.notes || null,
|
|
284
|
+
auth_scheme: m.auth_scheme || catalog?.schema?.auth || null,
|
|
285
|
+
rotate_every_days: m.rotate_every_days ?? null,
|
|
286
|
+
auto_rotate: !!m.auto_rotate,
|
|
287
|
+
next_rotate_due: due,
|
|
288
|
+
rotation_overdue,
|
|
289
|
+
notes: m.notes || null,
|
|
290
|
+
created_at: m.created_at || null,
|
|
291
|
+
updated_at: m.updated_at || null,
|
|
292
|
+
rotated_at: m.rotated_at || null,
|
|
293
|
+
headscale_url: m.headscale_url || null,
|
|
294
|
+
headscale_log_command: m.headscale_log_command || null,
|
|
295
|
+
usage_30d: usage,
|
|
296
|
+
tiers: catalog?.tiers || [],
|
|
297
|
+
// Provider account/usage tracking (must be ok or exempt — else error)
|
|
298
|
+
tracking_exempt: !!m.tracking_exempt,
|
|
299
|
+
tracking_status: m.tracking_status || (m.tracking_exempt ? 'exempt' : null),
|
|
300
|
+
tracking_error: m.tracking_error || null,
|
|
301
|
+
tracking_synced_at: m.tracking_synced_at || null,
|
|
302
|
+
tracking_probe: m.tracking_probe || null,
|
|
303
|
+
tracking_account: m.tracking_account || null,
|
|
304
|
+
tracking_usage: m.tracking_usage || null,
|
|
305
|
+
tracking_subscription: m.tracking_subscription || null,
|
|
306
|
+
account_api: m.account_api ?? null,
|
|
307
|
+
usage_api: m.usage_api ?? null,
|
|
308
|
+
subscription_api: m.subscription_api ?? null,
|
|
309
|
+
key_valid: m.key_valid ?? null,
|
|
310
|
+
shared_value_ok: !!m.shared_value_ok,
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const { byKey: sharedByKey } = buildSharedValueIndex(rows);
|
|
315
|
+
return rows.map((row) => {
|
|
316
|
+
const shared = sharedByKey.get(row.key);
|
|
317
|
+
const { fingerprint_full: _full, ...publicRow } = row;
|
|
318
|
+
if (!shared) {
|
|
319
|
+
return {
|
|
320
|
+
...publicRow,
|
|
321
|
+
shared_value: false,
|
|
322
|
+
shared_with: [],
|
|
323
|
+
shared_apps: [],
|
|
324
|
+
shared_value_error: null,
|
|
325
|
+
shared_value_severity: null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
...publicRow,
|
|
330
|
+
shared_value: true,
|
|
331
|
+
shared_with: shared.shared_with,
|
|
332
|
+
shared_apps: shared.shared_apps,
|
|
333
|
+
shared_value_error: shared.shared_value_error,
|
|
334
|
+
shared_value_severity: shared.shared_value_severity,
|
|
335
|
+
shared_fingerprint: shared.shared_fingerprint,
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Report of credential values reused across secret names / apps.
|
|
342
|
+
* healthy=false when any unwaived share exists.
|
|
343
|
+
*/
|
|
344
|
+
export async function getSharedValueHealth(brainDir) {
|
|
345
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
346
|
+
const meta = secrets[META_KEY].keys || {};
|
|
347
|
+
const rows = [];
|
|
348
|
+
for (const key of Object.keys(secrets)) {
|
|
349
|
+
if (key === META_KEY) continue;
|
|
350
|
+
const val = secrets[key];
|
|
351
|
+
if (val === undefined || val === null || val === '') continue;
|
|
352
|
+
const m = meta[key] || {};
|
|
353
|
+
const repos = Array.isArray(m.repos) ? m.repos.filter(Boolean) : [];
|
|
354
|
+
rows.push({
|
|
355
|
+
key,
|
|
356
|
+
set: true,
|
|
357
|
+
provider: m.provider || null,
|
|
358
|
+
repos,
|
|
359
|
+
repo: repos.length === 1 ? repos[0] : null,
|
|
360
|
+
fingerprint_full: secretValueFingerprint(String(val)),
|
|
361
|
+
masked:
|
|
362
|
+
typeof val === 'string' && val.length > 8
|
|
363
|
+
? `${val.slice(0, 4)}…${val.slice(-4)}`
|
|
364
|
+
: '••••••••',
|
|
365
|
+
shared_value_ok: !!m.shared_value_ok,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const { groups } = buildSharedValueIndex(rows);
|
|
369
|
+
const errorGroups = groups.filter((g) => g.severity === 'error');
|
|
370
|
+
const multiAppGroups = errorGroups.filter((g) => g.multi_app);
|
|
371
|
+
return {
|
|
372
|
+
healthy: errorGroups.length === 0,
|
|
373
|
+
groups,
|
|
374
|
+
error_groups: errorGroups.length,
|
|
375
|
+
multi_app_groups: multiAppGroups.length,
|
|
376
|
+
shared_keys: errorGroups.reduce((n, g) => n + g.count, 0),
|
|
377
|
+
message:
|
|
378
|
+
errorGroups.length === 0
|
|
379
|
+
? 'No shared credential values across secret names/apps'
|
|
380
|
+
: `SHARED KEY ERROR: ${errorGroups.length} credential value(s) reused across secret names/apps — issue unique keys per app`,
|
|
381
|
+
errors: errorGroups.map((g) => ({
|
|
382
|
+
fingerprint: g.fingerprint,
|
|
383
|
+
apps: g.apps,
|
|
384
|
+
keys: g.keys,
|
|
385
|
+
error: g.error,
|
|
386
|
+
})),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Full catalog payload for UI: keys + providers + usage totals.
|
|
392
|
+
*/
|
|
393
|
+
export async function getSecretsCatalog(brainDir) {
|
|
394
|
+
const { listProviders } = await import('./provider-catalog.mjs');
|
|
395
|
+
const keys = await listSecretsMeta(brainDir);
|
|
396
|
+
const usage30 = summarizeUsage(brainDir, { days: 30 });
|
|
397
|
+
const usage7 = summarizeUsage(brainDir, { days: 7 });
|
|
398
|
+
const budget = loadBudgetConfig(brainDir);
|
|
399
|
+
const monthlyPlanned = keys.reduce((s, k) => s + (Number(k.monthly_cost_usd) || 0), 0);
|
|
400
|
+
const overdue = keys.filter((k) => k.rotation_overdue);
|
|
401
|
+
const multiRepoViolations = [];
|
|
402
|
+
const developerKeys = keys.filter((k) => !k.repos?.length);
|
|
403
|
+
const productKeys = keys.filter((k) => k.repos?.length >= 1);
|
|
404
|
+
const byProvider = {};
|
|
405
|
+
for (const k of keys) {
|
|
406
|
+
const p = k.provider || 'unknown';
|
|
407
|
+
if (!byProvider[p]) byProvider[p] = { provider: p, keys: 0, cost_30d: 0, monthly_cost: 0 };
|
|
408
|
+
byProvider[p].keys++;
|
|
409
|
+
byProvider[p].cost_30d += k.usage_30d?.cost_usd || 0;
|
|
410
|
+
byProvider[p].monthly_cost += Number(k.monthly_cost_usd) || 0;
|
|
411
|
+
}
|
|
412
|
+
const setKeys = keys.filter((k) => k.set);
|
|
413
|
+
const trackingOk = setKeys.filter((k) => k.tracking_status === 'ok');
|
|
414
|
+
const trackingExempt = setKeys.filter((k) => k.tracking_status === 'exempt' || k.tracking_exempt);
|
|
415
|
+
const trackingErrors = setKeys.filter(
|
|
416
|
+
(k) => k.tracking_status !== 'ok' && k.tracking_status !== 'exempt' && !k.tracking_exempt,
|
|
417
|
+
);
|
|
418
|
+
const sharedKeys = setKeys.filter((k) => k.shared_value && k.shared_value_severity === 'error');
|
|
419
|
+
const sharedApps = setKeys.filter(
|
|
420
|
+
(k) => k.shared_value && k.shared_value_severity === 'error' && (k.shared_apps?.length || 0) > 1,
|
|
421
|
+
);
|
|
422
|
+
// Build unique shared groups from key annotations
|
|
423
|
+
const seenFp = new Set();
|
|
424
|
+
const sharedGroups = [];
|
|
425
|
+
for (const k of sharedKeys) {
|
|
426
|
+
const fp = k.shared_fingerprint || k.fingerprint;
|
|
427
|
+
if (!fp || seenFp.has(fp)) continue;
|
|
428
|
+
seenFp.add(fp);
|
|
429
|
+
sharedGroups.push({
|
|
430
|
+
fingerprint: fp,
|
|
431
|
+
apps: k.shared_apps || [],
|
|
432
|
+
keys: [k.key, ...(k.shared_with || []).map((s) => s.key)],
|
|
433
|
+
error: k.shared_value_error,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
keys,
|
|
438
|
+
providers: listProviders(),
|
|
439
|
+
summary: {
|
|
440
|
+
total_keys: keys.length,
|
|
441
|
+
providers_active: Object.keys(byProvider).length,
|
|
442
|
+
monthly_subscription_usd: monthlyPlanned,
|
|
443
|
+
multi_repo_violations: multiRepoViolations.length,
|
|
444
|
+
developer_keys: developerKeys.length,
|
|
445
|
+
product_keys: productKeys.length,
|
|
446
|
+
usage_7d: usage7,
|
|
447
|
+
usage_30d: usage30,
|
|
448
|
+
rotation_overdue: overdue.length,
|
|
449
|
+
budget: budget.config,
|
|
450
|
+
tracking_healthy: trackingErrors.length === 0,
|
|
451
|
+
tracking_ok: trackingOk.length,
|
|
452
|
+
tracking_exempt: trackingExempt.length,
|
|
453
|
+
tracking_errors: trackingErrors.length,
|
|
454
|
+
tracking_error_keys: trackingErrors.map((k) => ({
|
|
455
|
+
key: k.key,
|
|
456
|
+
provider: k.provider,
|
|
457
|
+
tracking_status: k.tracking_status || 'never',
|
|
458
|
+
error: k.tracking_error || 'Never synced — run secret account-sync',
|
|
459
|
+
})),
|
|
460
|
+
// Same credential material under multiple key names / repos
|
|
461
|
+
shared_value_healthy: sharedKeys.length === 0,
|
|
462
|
+
shared_value_keys: sharedKeys.length,
|
|
463
|
+
shared_value_multi_app_keys: sharedApps.length,
|
|
464
|
+
shared_value_groups: sharedGroups.length,
|
|
465
|
+
shared_value_errors: sharedGroups,
|
|
466
|
+
},
|
|
467
|
+
by_provider: Object.values(byProvider).sort((a, b) => b.cost_30d - a.cost_30d),
|
|
468
|
+
store: resolveSecretsPath(brainDir),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Update metadata only (no value change).
|
|
474
|
+
*/
|
|
475
|
+
export async function updateSecretMeta(brainDir, key, patch = {}, opts = {}) {
|
|
476
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
477
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
478
|
+
throw new Error(`Secret not found: ${key}`);
|
|
479
|
+
}
|
|
480
|
+
const prev = secrets[META_KEY].keys[key] || {};
|
|
481
|
+
secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
|
|
482
|
+
...patch,
|
|
483
|
+
updated_at: new Date().toISOString(),
|
|
484
|
+
created_at: prev.created_at || new Date().toISOString(),
|
|
485
|
+
});
|
|
486
|
+
await saveSecrets(brainDir, secrets);
|
|
487
|
+
appendAudit(brainDir, {
|
|
488
|
+
action: 'meta',
|
|
489
|
+
key,
|
|
490
|
+
actor: opts.actor || 'cli',
|
|
491
|
+
fields: Object.keys(patch),
|
|
492
|
+
});
|
|
493
|
+
return listSecretsMeta(brainDir).then((rows) => rows.find((r) => r.key === key));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Secrets due for rotation (auto_rotate or overdue).
|
|
498
|
+
*/
|
|
499
|
+
export async function listRotationDue(brainDir, { autoOnly = false } = {}) {
|
|
500
|
+
const keys = await listSecretsMeta(brainDir);
|
|
501
|
+
return keys.filter((k) => {
|
|
502
|
+
if (!k.next_rotate_due) return false;
|
|
503
|
+
if (!k.rotation_overdue) return false;
|
|
504
|
+
if (autoOnly && !k.auto_rotate) return false;
|
|
505
|
+
return true;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Set a secret. Value never returned in audit.
|
|
511
|
+
*/
|
|
512
|
+
export async function setSecret(brainDir, key, value, opts = {}) {
|
|
513
|
+
if (!key || !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(key)) {
|
|
514
|
+
throw new Error('Invalid secret key. Use letters, numbers, _ . - (start with a letter).');
|
|
515
|
+
}
|
|
516
|
+
if (key === META_KEY) throw new Error('Reserved key name');
|
|
517
|
+
if (value === undefined || value === null || value === '') {
|
|
518
|
+
throw new Error('Secret value must be non-empty');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
522
|
+
const prev = secrets[META_KEY].keys[key] || {};
|
|
523
|
+
const now = new Date().toISOString();
|
|
524
|
+
secrets[key] = String(value);
|
|
525
|
+
secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
|
|
526
|
+
scope: opts.scope || prev.scope || 'global',
|
|
527
|
+
provider: opts.provider || prev.provider || null,
|
|
528
|
+
repos: opts.repos !== undefined ? opts.repos : prev.repos,
|
|
529
|
+
tags: opts.tags !== undefined ? opts.tags : prev.tags,
|
|
530
|
+
subscription_tier: opts.subscription_tier !== undefined ? opts.subscription_tier : prev.subscription_tier,
|
|
531
|
+
monthly_cost_usd: opts.monthly_cost_usd !== undefined ? opts.monthly_cost_usd : prev.monthly_cost_usd,
|
|
532
|
+
monthly_cap_usd: opts.monthly_cap_usd !== undefined ? opts.monthly_cap_usd : prev.monthly_cap_usd,
|
|
533
|
+
api_docs_url: opts.api_docs_url !== undefined ? opts.api_docs_url : prev.api_docs_url,
|
|
534
|
+
headscale_url: opts.headscale_url !== undefined ? opts.headscale_url : prev.headscale_url,
|
|
535
|
+
headscale_log_command:
|
|
536
|
+
opts.headscale_log_command !== undefined ? opts.headscale_log_command : prev.headscale_log_command,
|
|
537
|
+
rotate_every_days: opts.rotate_every_days !== undefined ? opts.rotate_every_days : prev.rotate_every_days,
|
|
538
|
+
auto_rotate: opts.auto_rotate !== undefined ? opts.auto_rotate : prev.auto_rotate,
|
|
539
|
+
notes: opts.notes !== undefined ? opts.notes : prev.notes,
|
|
540
|
+
project_path: opts.project_path !== undefined ? opts.project_path : prev.project_path,
|
|
541
|
+
label: opts.label !== undefined ? opts.label : prev.label,
|
|
542
|
+
created_at: prev.created_at || now,
|
|
543
|
+
updated_at: now,
|
|
544
|
+
});
|
|
545
|
+
await saveSecrets(brainDir, secrets);
|
|
546
|
+
appendAudit(brainDir, {
|
|
547
|
+
action: 'set',
|
|
548
|
+
key,
|
|
549
|
+
scope: secrets[META_KEY].keys[key].scope,
|
|
550
|
+
provider: secrets[META_KEY].keys[key].provider,
|
|
551
|
+
actor: opts.actor || 'cli',
|
|
552
|
+
project_id: opts.project_id || null,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
return { key, set: true };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Get a secret value (CLI only — do not inject into surfaces).
|
|
560
|
+
*/
|
|
561
|
+
export async function getSecret(brainDir, key, opts = {}) {
|
|
562
|
+
const secrets = await loadSecrets(brainDir);
|
|
563
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
564
|
+
return { found: false, key };
|
|
565
|
+
}
|
|
566
|
+
appendAudit(brainDir, {
|
|
567
|
+
action: opts.action || 'get',
|
|
568
|
+
key,
|
|
569
|
+
actor: opts.actor || 'cli',
|
|
570
|
+
project_id: opts.project_id || null,
|
|
571
|
+
});
|
|
572
|
+
return { found: true, key, value: secrets[key] };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Delete a secret.
|
|
577
|
+
*/
|
|
578
|
+
export async function deleteSecret(brainDir, key, opts = {}) {
|
|
579
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
580
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
581
|
+
return { found: false, key };
|
|
582
|
+
}
|
|
583
|
+
delete secrets[key];
|
|
584
|
+
if (secrets[META_KEY].keys) delete secrets[META_KEY].keys[key];
|
|
585
|
+
await saveSecrets(brainDir, secrets);
|
|
586
|
+
appendAudit(brainDir, { action: 'delete', key, actor: opts.actor || 'cli' });
|
|
587
|
+
return { found: true, key, deleted: true };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Rotate: set new value, mark rotated_at.
|
|
592
|
+
*/
|
|
593
|
+
export async function rotateSecret(brainDir, key, newValue, opts = {}) {
|
|
594
|
+
if (!newValue) throw new Error('New value required for rotate');
|
|
595
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
596
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
597
|
+
throw new Error(`Secret not found: ${key}`);
|
|
598
|
+
}
|
|
599
|
+
secrets[key] = String(newValue);
|
|
600
|
+
const prev = secrets[META_KEY].keys[key] || {};
|
|
601
|
+
const now = new Date().toISOString();
|
|
602
|
+
secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
|
|
603
|
+
scope: opts.scope || prev.scope || 'global',
|
|
604
|
+
provider: opts.provider || prev.provider || null,
|
|
605
|
+
updated_at: now,
|
|
606
|
+
rotated_at: now,
|
|
607
|
+
});
|
|
608
|
+
await saveSecrets(brainDir, secrets);
|
|
609
|
+
appendAudit(brainDir, { action: 'rotate', key, actor: opts.actor || 'cli' });
|
|
610
|
+
return { key, rotated: true, next_rotate_due: nextRotateDue(secrets[META_KEY].keys[key]) };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Read last N audit lines (no values).
|
|
615
|
+
*/
|
|
616
|
+
export function readSecretAudit(brainDir, { limit = 50 } = {}) {
|
|
617
|
+
const auditPath = resolveAuditPath(brainDir);
|
|
618
|
+
return authLedger(auditPath).slice(-limit);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Record a usage/cost event (no secrets).
|
|
623
|
+
*/
|
|
624
|
+
export function recordUsage(brainDir, event) {
|
|
625
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
626
|
+
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
|
|
627
|
+
const row = {
|
|
628
|
+
ts: new Date().toISOString(),
|
|
629
|
+
provider: event.provider || 'unknown',
|
|
630
|
+
model: event.model || null,
|
|
631
|
+
input_tokens: event.input_tokens || 0,
|
|
632
|
+
output_tokens: event.output_tokens || 0,
|
|
633
|
+
cost_usd: event.cost_usd ?? null,
|
|
634
|
+
source: event.source || 'cli',
|
|
635
|
+
key_ref: event.key_ref || null, // secret *name* only, never value
|
|
636
|
+
};
|
|
637
|
+
appendAuthMetadata(usagePath, row);
|
|
638
|
+
return row;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Load budget.yml if present.
|
|
643
|
+
*/
|
|
644
|
+
export function loadBudgetConfig(brainDir) {
|
|
645
|
+
const candidates = [
|
|
646
|
+
path.join(brainDir, 'config', 'budget.yml'),
|
|
647
|
+
path.join(process.cwd(), 'config', 'budget.yml'),
|
|
648
|
+
];
|
|
649
|
+
for (const p of candidates) {
|
|
650
|
+
if (!fs.existsSync(p)) continue;
|
|
651
|
+
try {
|
|
652
|
+
return { path: p, config: YAML.parse(fs.readFileSync(p, 'utf8')) || {} };
|
|
653
|
+
} catch {
|
|
654
|
+
return { path: p, config: {}, error: 'parse failed' };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
path: null,
|
|
659
|
+
config: { daily_cap_usd: 5, weekly_cap_usd: 25 },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** @deprecated alias */
|
|
664
|
+
export const loadBudget = loadBudgetConfig;
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Sum usage.jsonl costs for last N days.
|
|
668
|
+
*/
|
|
669
|
+
export function summarizeUsage(brainDir, { days = 1 } = {}) {
|
|
670
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
671
|
+
const cutoff = Date.now() - days * 86400000;
|
|
672
|
+
let events = 0;
|
|
673
|
+
let cost = 0;
|
|
674
|
+
let input = 0;
|
|
675
|
+
let output = 0;
|
|
676
|
+
for (const row of authLedger(usagePath)) {
|
|
677
|
+
const t = new Date(row.ts).getTime();
|
|
678
|
+
if (t < cutoff) continue;
|
|
679
|
+
events++;
|
|
680
|
+
cost += Number(row.cost_usd) || 0;
|
|
681
|
+
input += Number(row.input_tokens) || 0;
|
|
682
|
+
output += Number(row.output_tokens) || 0;
|
|
683
|
+
}
|
|
684
|
+
return { events, cost_usd: cost, input_tokens: input, output_tokens: output, days };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Usage rollup by key_ref and provider for last N days.
|
|
689
|
+
*/
|
|
690
|
+
export function summarizeUsageByKey(brainDir, { days = 30 } = {}) {
|
|
691
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
692
|
+
const by_key = {};
|
|
693
|
+
const by_provider = {};
|
|
694
|
+
const cutoff = Date.now() - days * 86400000;
|
|
695
|
+
for (const row of authLedger(usagePath)) {
|
|
696
|
+
const t = new Date(row.ts).getTime();
|
|
697
|
+
if (t < cutoff) continue;
|
|
698
|
+
const kref = row.key_ref || '_unattributed';
|
|
699
|
+
const prov = row.provider || 'unknown';
|
|
700
|
+
if (!by_key[kref]) {
|
|
701
|
+
by_key[kref] = { events: 0, cost_usd: 0, input_tokens: 0, output_tokens: 0 };
|
|
702
|
+
}
|
|
703
|
+
by_key[kref].events++;
|
|
704
|
+
by_key[kref].cost_usd += Number(row.cost_usd) || 0;
|
|
705
|
+
by_key[kref].input_tokens += Number(row.input_tokens) || 0;
|
|
706
|
+
by_key[kref].output_tokens += Number(row.output_tokens) || 0;
|
|
707
|
+
if (!by_provider[prov]) {
|
|
708
|
+
by_provider[prov] = { events: 0, cost_usd: 0, input_tokens: 0, output_tokens: 0 };
|
|
709
|
+
}
|
|
710
|
+
by_provider[prov].events++;
|
|
711
|
+
by_provider[prov].cost_usd += Number(row.cost_usd) || 0;
|
|
712
|
+
by_provider[prov].input_tokens += Number(row.input_tokens) || 0;
|
|
713
|
+
by_provider[prov].output_tokens += Number(row.output_tokens) || 0;
|
|
714
|
+
}
|
|
715
|
+
return { by_key, by_provider, days };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Recent usage rows (no secrets), newest last.
|
|
720
|
+
*/
|
|
721
|
+
export function listUsageEvents(brainDir, { limit = 100, key_ref = null, days = 30 } = {}) {
|
|
722
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
723
|
+
const cutoff = Date.now() - days * 86400000;
|
|
724
|
+
const rows = [];
|
|
725
|
+
for (const row of authLedger(usagePath)) {
|
|
726
|
+
const t = new Date(row.ts).getTime();
|
|
727
|
+
if (t < cutoff) continue;
|
|
728
|
+
if (key_ref && row.key_ref !== key_ref) continue;
|
|
729
|
+
rows.push(row);
|
|
730
|
+
}
|
|
731
|
+
return rows.slice(-limit);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* True if any secret value appears in text (for surface/openwiki conformance).
|
|
736
|
+
*/
|
|
737
|
+
export async function textContainsSecrets(brainDir, text) {
|
|
738
|
+
if (!text) return { leak: false, keys: [] };
|
|
739
|
+
const secrets = await loadSecrets(brainDir);
|
|
740
|
+
const leaked = [];
|
|
741
|
+
for (const [key, val] of Object.entries(secrets)) {
|
|
742
|
+
if (key === META_KEY) continue;
|
|
743
|
+
if (typeof val === 'string' && val.length >= 8 && text.includes(val)) {
|
|
744
|
+
leaked.push(key);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return { leak: leaked.length > 0, keys: leaked };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Resolve brain dir for secrets (global or project).
|
|
752
|
+
*/
|
|
753
|
+
export function defaultBrainDir() {
|
|
754
|
+
return process.env.TR_SECRETS_BRAIN || process.env._TR_TEST_BRAIN_DIR || process.env.TR_BRAIN ||
|
|
755
|
+
path.join(process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(os.homedir(), '.agent'), 'skills', 'total-recall');
|
|
756
|
+
}
|