total-recall-brain 3.41.1 → 3.41.2
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/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-auth",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "White-label Total Recall auth plugin: Passkey biometric FIDO2 and Google OAuth2 authentication.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "cli.mjs",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "node --test test/*.test.mjs"
|
|
8
|
+
"test": "node --test test/*.test.mjs && vitest run test"
|
|
9
9
|
},
|
|
10
10
|
"keywords": [
|
|
11
11
|
"total-recall-plugin",
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"plugin.json",
|
|
29
29
|
"README.md",
|
|
30
30
|
"src/",
|
|
31
|
-
"test/",
|
|
32
31
|
"scripts/",
|
|
33
32
|
"!src/**/*.spec.mjs",
|
|
34
|
-
"ui/"
|
|
33
|
+
"ui/",
|
|
34
|
+
"!src/**/*.test.mjs"
|
|
35
35
|
],
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
|
|
3
3
|
"id": "auth",
|
|
4
4
|
"name": "Auth & Secrets",
|
|
5
|
-
"version": "1.2.
|
|
5
|
+
"version": "1.2.3",
|
|
6
6
|
"description": "Hardware WebAuthn biometric FIDO2 passkeys, OAuth session security, and access control.",
|
|
7
7
|
"author": "Total Recall contributors",
|
|
8
8
|
"license": "MIT",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import {
|
|
16
16
|
setSecret,
|
|
17
17
|
getSecret,
|
|
18
|
+
secretValueFingerprint,
|
|
19
|
+
resolveSecretKey,
|
|
18
20
|
listSecretsMeta,
|
|
19
21
|
rotateSecret,
|
|
20
22
|
deleteSecret,
|
|
@@ -55,9 +57,11 @@ import {
|
|
|
55
57
|
removeRemoteTarget,
|
|
56
58
|
deployEnvToRemote,
|
|
57
59
|
deployKeyToRemotes,
|
|
60
|
+
verifyRemote,
|
|
61
|
+
importFromPm2,
|
|
58
62
|
} from './secrets-remote-deploy.mjs';
|
|
59
63
|
import { listProviders, getProvider } from './provider-catalog.mjs';
|
|
60
|
-
import {
|
|
64
|
+
import { parseLayerFlag, detectProjectBrain } from './agent-dir.mjs';
|
|
61
65
|
import { hostSecretsBrainDir } from './host-secrets-store.mjs';
|
|
62
66
|
import fs from 'node:fs';
|
|
63
67
|
import { ensureProjectId } from './project-id.mjs';
|
|
@@ -233,12 +237,21 @@ function printHelp() {
|
|
|
233
237
|
list Show configured targets (no secret values)
|
|
234
238
|
remove <name> Delete a target
|
|
235
239
|
deploy <name> Push the SSOT projection to that target now
|
|
236
|
-
[--dry-run] [--keys k1,k2]
|
|
240
|
+
[--dry-run] [--keys k1,k2] [--path <repo>]
|
|
241
|
+
verify <name> Compare the deployed file with the store by fingerprint
|
|
242
|
+
(match/differs/missing/duplicate; no values) [--keys] [--path]
|
|
243
|
+
import-pm2 <name> Move named keys from a pm2 app's captured env into the store
|
|
244
|
+
--app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r]
|
|
245
|
+
merge-store <dir> Fold a stray per-repo store into this one (dry run unless --apply).
|
|
246
|
+
Missing keys are imported, equal keys gain the binding, differing
|
|
247
|
+
keys are conflicts: settle with --take K or --rename SRC=DEST
|
|
248
|
+
--repo <name> Bind imported keys to this repo
|
|
249
|
+
--bind-same Also bind keys whose value already matches (only for an app's own store)
|
|
250
|
+
--dates-from-source Restore an older value date for keys whose value is identical
|
|
237
251
|
diff-env Compare repo .env files against the store (no values printed)
|
|
238
252
|
--path <dir> Single repo (default: every project-registry path)
|
|
239
253
|
--filename .env File to compare (default .env; server/<name> also checked)
|
|
240
254
|
--strict Exit 1 on any drift or unadopted key
|
|
241
|
-
--layer project Compare against the project-local store (default: global)
|
|
242
255
|
import-env Import selected local .env credentials into auth
|
|
243
256
|
--all / --file / --overwrite / --dry-run
|
|
244
257
|
catalog Full catalog: keys, providers, usage, rotation, cost, tracking
|
|
@@ -258,6 +271,7 @@ function printHelp() {
|
|
|
258
271
|
--shared-ok Allow this key to share its value with other secret names
|
|
259
272
|
--no-shared-ok
|
|
260
273
|
--repo <name> Bind to exactly ONE product repo (not multi)
|
|
274
|
+
--unbind <repo> Remove a repo binding (comma separated for several)
|
|
261
275
|
--repos <name> Same as --repo (single name only; multi rejected)
|
|
262
276
|
--tier <id> Subscription tier
|
|
263
277
|
--monthly-cost <n> Planned monthly $
|
|
@@ -272,6 +286,10 @@ function printHelp() {
|
|
|
272
286
|
--headscale-url <https-url>
|
|
273
287
|
rotation-due List keys overdue for rotation
|
|
274
288
|
--enqueue Create daemon tasks + browser-use prompts for each due key
|
|
289
|
+
fingerprint Identify a credential without printing it: length, type
|
|
290
|
+
prefix, fingerprint and the store keys holding the same
|
|
291
|
+
value. --from-clipboard | --stdin; --expect-prefix <p>
|
|
292
|
+
and --not <KEY> fail (exit 1) on a wrong value.
|
|
275
293
|
rotate-browser <key> Drive the provider console in TR's browser and rotate
|
|
276
294
|
--print-only Only print instructions
|
|
277
295
|
--headless Allow headless for verified recipes
|
|
@@ -379,7 +397,7 @@ function parseArgs(args) {
|
|
|
379
397
|
let i = 1;
|
|
380
398
|
if (out.command === 'remote') {
|
|
381
399
|
if (args[i] && !args[i].startsWith('--')) out.subcommand = args[i++];
|
|
382
|
-
if (['add', 'remove', 'rm', 'deploy'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
|
|
400
|
+
if (['add', 'remove', 'rm', 'deploy', 'verify', 'import-pm2'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
|
|
383
401
|
out.key = args[i++]; // reused as the remote target name
|
|
384
402
|
}
|
|
385
403
|
}
|
|
@@ -395,6 +413,7 @@ function parseArgs(args) {
|
|
|
395
413
|
'rm',
|
|
396
414
|
'generate',
|
|
397
415
|
'account-sync',
|
|
416
|
+
'merge-store',
|
|
398
417
|
].includes(out.command) &&
|
|
399
418
|
args[i] &&
|
|
400
419
|
!args[i].startsWith('--')
|
|
@@ -458,6 +477,21 @@ function parseArgs(args) {
|
|
|
458
477
|
case '--dry-run':
|
|
459
478
|
out.dryRun = true;
|
|
460
479
|
break;
|
|
480
|
+
case '--apply':
|
|
481
|
+
out.apply = true;
|
|
482
|
+
break;
|
|
483
|
+
case '--bind-same':
|
|
484
|
+
out.bindSame = true;
|
|
485
|
+
break;
|
|
486
|
+
case '--dates-from-source':
|
|
487
|
+
out.datesFromSource = true;
|
|
488
|
+
break;
|
|
489
|
+
case '--unbind':
|
|
490
|
+
out.unbind = (out.unbind || []).concat(String(args[++i] || '').split(',').map((v) => v.trim()).filter(Boolean));
|
|
491
|
+
break;
|
|
492
|
+
case '--take':
|
|
493
|
+
out.take = String(args[++i] || '').split(',').map((v) => v.trim()).filter(Boolean);
|
|
494
|
+
break;
|
|
461
495
|
case '--overwrite':
|
|
462
496
|
out.overwrite = true;
|
|
463
497
|
break;
|
|
@@ -507,6 +541,9 @@ function parseArgs(args) {
|
|
|
507
541
|
case '--label':
|
|
508
542
|
out.label = args[++i];
|
|
509
543
|
break;
|
|
544
|
+
case '--provides':
|
|
545
|
+
out.provides = args[++i];
|
|
546
|
+
break;
|
|
510
547
|
case '--project':
|
|
511
548
|
out.project = args[++i];
|
|
512
549
|
break;
|
|
@@ -517,8 +554,7 @@ function parseArgs(args) {
|
|
|
517
554
|
out.path = args[++i];
|
|
518
555
|
break;
|
|
519
556
|
case '--layer':
|
|
520
|
-
|
|
521
|
-
break;
|
|
557
|
+
throw new Error('Secrets have one store; --layer does not apply');
|
|
522
558
|
case '--all-projects':
|
|
523
559
|
out.allProjects = true;
|
|
524
560
|
break;
|
|
@@ -607,6 +643,15 @@ function parseArgs(args) {
|
|
|
607
643
|
case '--stdin':
|
|
608
644
|
out.stdin = true;
|
|
609
645
|
break;
|
|
646
|
+
case '--app':
|
|
647
|
+
out.app = args[++i];
|
|
648
|
+
break;
|
|
649
|
+
case '--expect-prefix':
|
|
650
|
+
out.expectPrefix = args[++i];
|
|
651
|
+
break;
|
|
652
|
+
case '--not':
|
|
653
|
+
out.notKey = args[++i];
|
|
654
|
+
break;
|
|
610
655
|
case '--launch-agent':
|
|
611
656
|
out.launchAgents.push(args[++i]);
|
|
612
657
|
break;
|
|
@@ -637,6 +682,21 @@ function parseArgs(args) {
|
|
|
637
682
|
return out;
|
|
638
683
|
}
|
|
639
684
|
|
|
685
|
+
/** The host's single secrets store; TR_SECRETS_BRAIN is the only override (deploy/ops/tests). */
|
|
686
|
+
export function secretsStoreDir() {
|
|
687
|
+
return hostSecretsBrainDir();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Name of the repo the command runs in (its project root's directory name), or null outside one. */
|
|
691
|
+
function currentRepoName() {
|
|
692
|
+
try {
|
|
693
|
+
const project = detectProjectBrain(process.cwd());
|
|
694
|
+
return project ? path.basename(project.projectRoot || path.dirname(path.dirname(project.agentDir))) : null;
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
640
700
|
/** Id of the project the command runs in (created on first use), or null outside a project. */
|
|
641
701
|
function currentProjectId() {
|
|
642
702
|
try {
|
|
@@ -649,24 +709,30 @@ function currentProjectId() {
|
|
|
649
709
|
|
|
650
710
|
export default async function secretCli(argv) {
|
|
651
711
|
const { layer, remainingArgs } = parseLayerFlag(argv);
|
|
652
|
-
|
|
712
|
+
let opts;
|
|
713
|
+
try {
|
|
714
|
+
opts = parseArgs(remainingArgs);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
console.error(`❌ ${err.message}`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
653
719
|
if (opts.help || !opts.command) {
|
|
654
720
|
printHelp();
|
|
655
721
|
return;
|
|
656
722
|
}
|
|
657
723
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
console.error(`❌ ${err.message}`);
|
|
724
|
+
// One store per host. A project brain in the cwd never selects a different
|
|
725
|
+
// secrets.enc: that is how per-repo stores appeared and drifted from the real
|
|
726
|
+
// one. Repo binding lives in each key's metadata (`meta <key> --repo`).
|
|
727
|
+
if (layer === 'project') {
|
|
728
|
+
console.error('❌ Secrets have one store; --project does not apply. Bind a key to a repo with `auth secret meta <key> --repo <name>`.');
|
|
664
729
|
process.exit(1);
|
|
665
730
|
}
|
|
731
|
+
const brainDir = secretsStoreDir();
|
|
666
732
|
|
|
667
|
-
if (opts.stdin) {
|
|
733
|
+
if (opts.stdin && opts.command !== 'fingerprint') {
|
|
668
734
|
if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
|
|
669
|
-
console.error('❌ --stdin is only valid with `set`, `rotate`, or `
|
|
735
|
+
console.error('❌ --stdin is only valid with `set`, `rotate`, `rekey`, or `fingerprint`');
|
|
670
736
|
process.exit(1);
|
|
671
737
|
}
|
|
672
738
|
if (opts.command !== 'rekey' && !opts.key) {
|
|
@@ -776,7 +842,8 @@ export default async function secretCli(argv) {
|
|
|
776
842
|
console.error('Usage: total-recall auth secret set <key> <value>');
|
|
777
843
|
process.exit(1);
|
|
778
844
|
}
|
|
779
|
-
await
|
|
845
|
+
const setKey = await resolveSecretKey(brainDir, opts.key, currentRepoName());
|
|
846
|
+
await setSecret(brainDir, setKey, opts.value, {
|
|
780
847
|
tags: opts.tags != null ? opts.tags : undefined,
|
|
781
848
|
project_id: currentProjectId(),
|
|
782
849
|
provider: opts.provider || inferProvider(opts.key),
|
|
@@ -793,7 +860,7 @@ export default async function secretCli(argv) {
|
|
|
793
860
|
project_path: opts.project,
|
|
794
861
|
headscale_url: opts.headscale_url,
|
|
795
862
|
});
|
|
796
|
-
console.log(`\n ✅ Secret set: ${opts.key}`);
|
|
863
|
+
console.log(`\n ✅ Secret set: ${setKey}${setKey !== opts.key ? ` (provides ${opts.key} for this repo)` : ''}`);
|
|
797
864
|
console.log(` store: ${resolveSecretsPath(brainDir)}`);
|
|
798
865
|
console.log(` (value not printed)\n`);
|
|
799
866
|
return;
|
|
@@ -1019,6 +1086,14 @@ export default async function secretCli(argv) {
|
|
|
1019
1086
|
}
|
|
1020
1087
|
const patch = {};
|
|
1021
1088
|
if (opts.repos.length) patch.repos = opts.repos;
|
|
1089
|
+
if (opts.unbind?.length) {
|
|
1090
|
+
const row = (await listSecretsMeta(brainDir)).find((r) => r.key === opts.key);
|
|
1091
|
+
if (!row) {
|
|
1092
|
+
console.error(`❌ Secret not found: ${opts.key}`);
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
patch.repos = (patch.repos || row.repos || []).filter((r) => !opts.unbind.includes(r));
|
|
1096
|
+
}
|
|
1022
1097
|
if (opts.tier != null) patch.subscription_tier = opts.tier;
|
|
1023
1098
|
if (opts.monthly_cost != null) patch.monthly_cost_usd = opts.monthly_cost;
|
|
1024
1099
|
if (opts.monthly_cap != null) patch.monthly_cap_usd = opts.monthly_cap;
|
|
@@ -1028,6 +1103,7 @@ export default async function secretCli(argv) {
|
|
|
1028
1103
|
if (opts.notes) patch.notes = opts.notes;
|
|
1029
1104
|
if (opts.tags != null) patch.tags = opts.tags;
|
|
1030
1105
|
if (opts.label) patch.label = opts.label;
|
|
1106
|
+
if (opts.provides !== undefined) patch.provides = opts.provides;
|
|
1031
1107
|
if (opts.project) patch.project_path = opts.project;
|
|
1032
1108
|
if (opts.provider) patch.provider = opts.provider;
|
|
1033
1109
|
if (opts.scope) patch.scope = opts.scope;
|
|
@@ -1081,7 +1157,8 @@ export default async function secretCli(argv) {
|
|
|
1081
1157
|
process.exit(1);
|
|
1082
1158
|
}
|
|
1083
1159
|
const project_id = currentProjectId();
|
|
1084
|
-
const
|
|
1160
|
+
const resolved = await resolveSecretKey(brainDir, opts.key, currentRepoName());
|
|
1161
|
+
const r = await getSecret(brainDir, resolved, { project_id });
|
|
1085
1162
|
if (opts.json) {
|
|
1086
1163
|
console.log(JSON.stringify(r.found ? { found: true, value: r.value } : { found: false }));
|
|
1087
1164
|
return;
|
|
@@ -1097,6 +1174,13 @@ export default async function secretCli(argv) {
|
|
|
1097
1174
|
|
|
1098
1175
|
if (opts.command === 'list') {
|
|
1099
1176
|
const rows = await listSecretsMeta(brainDir);
|
|
1177
|
+
if (opts.json) {
|
|
1178
|
+
// Metadata for monitoring: bindings, tags, provides, provider and
|
|
1179
|
+
// verification. Never the value, its full hash or a masked preview.
|
|
1180
|
+
const safe = rows.map(({ masked, fingerprint_full, value, ...row }) => row);
|
|
1181
|
+
console.log(JSON.stringify({ store: resolveSecretsPath(brainDir), keys: safe }, null, 2));
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1100
1184
|
if (!rows.length) {
|
|
1101
1185
|
console.log(`\n No secrets in ${resolveSecretsPath(brainDir)}\n`);
|
|
1102
1186
|
return;
|
|
@@ -1125,6 +1209,33 @@ export default async function secretCli(argv) {
|
|
|
1125
1209
|
return;
|
|
1126
1210
|
}
|
|
1127
1211
|
|
|
1212
|
+
if (opts.command === 'fingerprint') {
|
|
1213
|
+
// Identify a credential without seeing it: which store keys hold the same
|
|
1214
|
+
// value, its length and its non-secret type prefix. Lets an operator check
|
|
1215
|
+
// the clipboard holds the new key before `rotate --from-clipboard`.
|
|
1216
|
+
const value = opts.fromClipboard ? readSystemClipboard() : opts.stdin ? readStdinValue() : '';
|
|
1217
|
+
if (!value) {
|
|
1218
|
+
console.error('Usage: total-recall auth secret fingerprint --from-clipboard|--stdin [--expect-prefix <p>] [--not <KEY>] [--json]');
|
|
1219
|
+
process.exit(1);
|
|
1220
|
+
}
|
|
1221
|
+
const fp = secretValueFingerprint(value);
|
|
1222
|
+
const matches = (await listSecretsMeta(brainDir)).filter((row) => row.fingerprint_full === fp).map((row) => row.key);
|
|
1223
|
+
const prefix = (value.match(/^(?:(?:sk|rk|pk)_(?:live|test)_|whsec_|ghp_|github_pat_|npm_|dop_v1_|sk-or-v1-|sk-ant-|AIza|tr_)/) || [null])[0];
|
|
1224
|
+
const r = { length: value.length, prefix, fingerprint: fp.slice(0, 12), whitespace: /\s/.test(value), store_matches: matches };
|
|
1225
|
+
const problems = [];
|
|
1226
|
+
if (opts.expectPrefix && !value.startsWith(opts.expectPrefix)) problems.push(`does not start with ${opts.expectPrefix}`);
|
|
1227
|
+
if (opts.notKey && matches.includes(opts.notKey)) problems.push(`equals the current ${opts.notKey}`);
|
|
1228
|
+
if (opts.json) console.log(JSON.stringify({ ...r, ok: !problems.length, problems }, null, 2));
|
|
1229
|
+
else {
|
|
1230
|
+
console.log(`\n 🔏 length ${r.length} prefix ${r.prefix || '(none)'} fingerprint ${r.fingerprint}${r.whitespace ? ' CONTAINS WHITESPACE' : ''}`);
|
|
1231
|
+
console.log(` store keys with this value: ${matches.join(', ') || 'none'}`);
|
|
1232
|
+
for (const p of problems) console.log(` ❌ ${p}`);
|
|
1233
|
+
console.log('');
|
|
1234
|
+
}
|
|
1235
|
+
if (problems.length) process.exitCode = 1;
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1128
1239
|
if (opts.command === 'remote') {
|
|
1129
1240
|
if (!opts.subcommand) {
|
|
1130
1241
|
console.error(
|
|
@@ -1132,7 +1243,9 @@ export default async function secretCli(argv) {
|
|
|
1132
1243
|
' add <name> --host <h> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]\n' +
|
|
1133
1244
|
' list\n' +
|
|
1134
1245
|
' remove <name>\n' +
|
|
1135
|
-
' deploy <name> [--dry-run] [--keys k1,k2]'
|
|
1246
|
+
' deploy <name> [--dry-run] [--keys k1,k2] [--path <repo>]\n' +
|
|
1247
|
+
' verify <name> [--keys k1,k2] [--path <repo>] [--json]\n' +
|
|
1248
|
+
' import-pm2 <name> --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r] [--json]',
|
|
1136
1249
|
);
|
|
1137
1250
|
process.exit(1);
|
|
1138
1251
|
}
|
|
@@ -1198,6 +1311,7 @@ export default async function secretCli(argv) {
|
|
|
1198
1311
|
dryRun: opts.dryRun,
|
|
1199
1312
|
keys: opts.keys.length ? opts.keys : undefined,
|
|
1200
1313
|
rename: opts.rename,
|
|
1314
|
+
projectPath: opts.path ? path.resolve(opts.path) : undefined,
|
|
1201
1315
|
});
|
|
1202
1316
|
if (r.dryRun) {
|
|
1203
1317
|
console.log(`\n 📤 dry-run remote deploy → ${r.target} (${r.host})`);
|
|
@@ -1211,6 +1325,50 @@ export default async function secretCli(argv) {
|
|
|
1211
1325
|
return;
|
|
1212
1326
|
}
|
|
1213
1327
|
|
|
1328
|
+
if (opts.subcommand === 'verify') {
|
|
1329
|
+
if (!opts.key) {
|
|
1330
|
+
console.error('Usage: total-recall auth secret remote verify <name> [--keys k1,k2] [--path <repo>] [--json]');
|
|
1331
|
+
process.exit(1);
|
|
1332
|
+
}
|
|
1333
|
+
const r = await verifyRemote(brainDir, opts.key, {
|
|
1334
|
+
keys: opts.keys.length ? opts.keys : undefined,
|
|
1335
|
+
projectPath: opts.path ? path.resolve(opts.path) : undefined,
|
|
1336
|
+
});
|
|
1337
|
+
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
1338
|
+
else {
|
|
1339
|
+
console.log(`\n 🔎 ${r.target} (${r.host}:${r.remoteFile})`);
|
|
1340
|
+
for (const row of r.rows) {
|
|
1341
|
+
const mark = row.status === 'match' && !row.duplicates ? '✅' : '❌';
|
|
1342
|
+
console.log(` ${mark} ${row.key.padEnd(36)} ${row.status}${row.duplicates ? ` (+${row.duplicates} duplicate)` : ''}`);
|
|
1343
|
+
}
|
|
1344
|
+
console.log(` ${r.remoteOnly.length} key(s) on the remote are not in the projection`);
|
|
1345
|
+
console.log(` ${r.ok ? 'in sync' : 'DRIFT'} (values compared by fingerprint; none printed)\n`);
|
|
1346
|
+
}
|
|
1347
|
+
if (!r.ok) process.exitCode = 1;
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (opts.subcommand === 'import-pm2') {
|
|
1352
|
+
if (!opts.key || !opts.app || !opts.keys.length) {
|
|
1353
|
+
console.error('Usage: total-recall auth secret remote import-pm2 <name> --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r] [--json]');
|
|
1354
|
+
process.exit(1);
|
|
1355
|
+
}
|
|
1356
|
+
const r = await importFromPm2(brainDir, opts.key, {
|
|
1357
|
+
app: opts.app,
|
|
1358
|
+
keys: opts.keys,
|
|
1359
|
+
apply: opts.apply,
|
|
1360
|
+
overwrite: opts.overwrite,
|
|
1361
|
+
repos: opts.repos,
|
|
1362
|
+
});
|
|
1363
|
+
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
1364
|
+
else {
|
|
1365
|
+
console.log(`\n 📥 pm2 ${r.app} on ${r.target}${opts.apply ? '' : ' (dry run; add --apply to store)'}`);
|
|
1366
|
+
for (const row of r.rows) console.log(` • ${row.key.padEnd(36)} ${row.status}${row.applied ? ' → stored' : ''}`);
|
|
1367
|
+
console.log(' (values read in-process; none printed)\n');
|
|
1368
|
+
}
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1214
1372
|
console.error(`Unknown remote subcommand: ${opts.subcommand}`);
|
|
1215
1373
|
process.exit(1);
|
|
1216
1374
|
}
|
|
@@ -1515,17 +1673,42 @@ export default async function secretCli(argv) {
|
|
|
1515
1673
|
return;
|
|
1516
1674
|
}
|
|
1517
1675
|
|
|
1676
|
+
if (opts.command === 'merge-store') {
|
|
1677
|
+
if (!opts.key) {
|
|
1678
|
+
console.error('❌ Usage: auth secret merge-store <source brain dir> [--repo <name>] [--tags a,b] [--rename SRC=DEST] [--take K1,K2] [--apply] [--json]');
|
|
1679
|
+
process.exit(1);
|
|
1680
|
+
}
|
|
1681
|
+
const { mergeStore } = await import('./secrets-merge.mjs');
|
|
1682
|
+
const report = await mergeStore(brainDir, path.resolve(opts.key), {
|
|
1683
|
+
apply: opts.apply === true,
|
|
1684
|
+
bindSame: opts.bindSame === true,
|
|
1685
|
+
datesFromSource: opts.datesFromSource === true,
|
|
1686
|
+
repo: opts.repos.length ? opts.repos : undefined,
|
|
1687
|
+
tags: opts.tags,
|
|
1688
|
+
rename: opts.rename,
|
|
1689
|
+
take: opts.take,
|
|
1690
|
+
actor: 'cli',
|
|
1691
|
+
});
|
|
1692
|
+
if (opts.json) {
|
|
1693
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1694
|
+
} else {
|
|
1695
|
+
console.log(`\n merge-store ${report.applied ? 'applied' : 'dry run (add --apply to write)'} → ${resolveSecretsPath(brainDir)}`);
|
|
1696
|
+
console.log(` imported ${report.imported.length} · renamed ${report.renamed.length} · taken ${report.taken.length} · same ${report.same.length}${report.redated ? ` (${report.redated} redated)` : ''} · conflicts ${report.conflicts.length}`);
|
|
1697
|
+
for (const c of report.conflicts) {
|
|
1698
|
+
console.log(` ⚠️ ${c.key}: target ${c.target.fingerprint} (updated ${c.target.updated_at || '?'}, ${c.target.tracking_status || 'unverified'}) vs source ${c.source.fingerprint} (updated ${c.source.updated_at || '?'}, ${c.source.tracking_status || 'unverified'})`);
|
|
1699
|
+
}
|
|
1700
|
+
console.log('');
|
|
1701
|
+
}
|
|
1702
|
+
if (report.conflicts.length) process.exitCode = 2;
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1518
1706
|
if (opts.command === 'diff-env') {
|
|
1519
1707
|
const { diffEnvAgainstStore, resolveEnvFiles } = await import('./secrets-env-diff.mjs');
|
|
1520
1708
|
const { loadProjectRegistry } = await import('./secrets-env-export.mjs');
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
// to. A repo with its own .agent/ shadows the global store, so running
|
|
1525
|
-
// this from inside one would diff .env files against a project-local
|
|
1526
|
-
// store and report drift that is really just two different stores —
|
|
1527
|
-
// which is how the real drift stayed invisible. --layer project opts in.
|
|
1528
|
-
const compareBrainDir = opts.layer === 'project' ? brainDir : globalBrainDir;
|
|
1709
|
+
|
|
1710
|
+
// Always the single store: a repo's own .agent/ never selects another one.
|
|
1711
|
+
const compareBrainDir = brainDir;
|
|
1529
1712
|
|
|
1530
1713
|
const roots = opts.path
|
|
1531
1714
|
? [path.resolve(opts.path)]
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* class?: 'provider_api' | 'provider_browser' | 'self_generated' | 'manual',
|
|
16
16
|
* high_risk?: boolean,
|
|
17
17
|
* manual_keys?: string[],
|
|
18
|
+
* // Keys the provider issues that a name pattern would otherwise class as self-minted
|
|
19
|
+
* // (a Stripe webhook signing secret ends in _WEBHOOK_SECRET but only Stripe can issue it),
|
|
20
|
+
* // with the console page where each is rolled.
|
|
21
|
+
* issued_keys?: { [key: string]: string },
|
|
18
22
|
* // A provider that lets the account mint and revoke its own keys over its API. The management
|
|
19
23
|
* // credential is a separate stored secret; without it the provider cannot rotate itself, which
|
|
20
24
|
* // is what made 'provider_api' a class with no implementation.
|
|
@@ -345,7 +349,12 @@ export const PROVIDER_CATALOG = [
|
|
|
345
349
|
notes: 'Live keys are payment-critical. Roll with a grace window; never revoke before export confirms.',
|
|
346
350
|
},
|
|
347
351
|
// Stripe exposes no API to mint or roll secret keys — dashboard only, by design.
|
|
348
|
-
rotation: {
|
|
352
|
+
rotation: {
|
|
353
|
+
class: 'provider_browser',
|
|
354
|
+
high_risk: true,
|
|
355
|
+
// Signing secrets are issued per webhook endpoint; a self-minted value fails every signature check.
|
|
356
|
+
issued_keys: { STRIPE_WEBHOOK_SECRET: 'https://dashboard.stripe.com/webhooks' },
|
|
357
|
+
},
|
|
349
358
|
default_monthly_cap_usd: null,
|
|
350
359
|
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
351
360
|
},
|
|
@@ -115,6 +115,21 @@ export function getRotationPlan(key, meta = {}) {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
const issuer = meta.provider ? getProvider(meta.provider) : providerForKeyName(k);
|
|
119
|
+
const issuedConsole = Object.entries(issuer?.rotation?.issued_keys || {})
|
|
120
|
+
.find(([name]) => name.toUpperCase() === k.toUpperCase())?.[1];
|
|
121
|
+
if (issuedConsole) {
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
class: 'provider_browser',
|
|
125
|
+
provider: issuer.id,
|
|
126
|
+
console_url: issuedConsole,
|
|
127
|
+
high_risk: !!issuer.rotation.high_risk,
|
|
128
|
+
automatable: true,
|
|
129
|
+
reason: `${issuer.name} issues this value; roll it in the console, never mint it locally`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
if (matchesAny(k, SELF_GENERATED_PATTERNS)) {
|
|
119
134
|
return {
|
|
120
135
|
...base,
|
|
@@ -26,26 +26,58 @@ function envHeader() {
|
|
|
26
26
|
`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
30
|
+
|
|
31
|
+
function assignedKey(line) {
|
|
32
|
+
const m = ASSIGNMENT.exec(line);
|
|
33
|
+
return m ? m[1] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Drop assignments of `keys` from hand-managed text so the managed block is the only definition. */
|
|
37
|
+
function withoutKeys(text, keys) {
|
|
38
|
+
if (!keys.size) return text;
|
|
39
|
+
return text.split('\n').filter((line) => !keys.has(assignedKey(line))).join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* Merge a managed TR secrets block into an existing .env without wiping
|
|
31
44
|
* non-secret / hand-managed keys outside the BEGIN/END markers.
|
|
32
45
|
*
|
|
46
|
+
* A key the block defines is removed from the text outside the block: dotenv
|
|
47
|
+
* loaders disagree on whether the first or last assignment wins, so a stale
|
|
48
|
+
* copy left outside would shadow the rotated value under some of them.
|
|
49
|
+
* With `partial`, the block carries only some keys (a `--keys` deploy), so the
|
|
50
|
+
* previous block's other entries are kept rather than dropped.
|
|
51
|
+
*
|
|
33
52
|
* @param {string} existing - current file contents (or '')
|
|
34
53
|
* @param {string} managedBody - full block including BEGIN/END marks
|
|
54
|
+
* @param {{ partial?: boolean }} [opts]
|
|
35
55
|
* @returns {string}
|
|
36
56
|
*/
|
|
37
|
-
export function mergeEnvManagedBlock(existing, managedBody) {
|
|
38
|
-
|
|
57
|
+
export function mergeEnvManagedBlock(existing, managedBody, { partial = false } = {}) {
|
|
58
|
+
let managed = String(managedBody || '').trimEnd() + '\n';
|
|
39
59
|
const text = existing == null ? '' : String(existing);
|
|
40
60
|
|
|
41
61
|
if (!text.trim()) return managed;
|
|
42
62
|
|
|
43
63
|
const beginIdx = text.indexOf(BEGIN_MARK);
|
|
44
64
|
const endIdx = text.indexOf(END_MARK);
|
|
65
|
+
const hasBlock = beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx;
|
|
66
|
+
|
|
67
|
+
if (partial && hasBlock) {
|
|
68
|
+
const fresh = new Set(managed.split('\n').map(assignedKey).filter(Boolean));
|
|
69
|
+
const kept = text.slice(beginIdx, endIdx).split('\n')
|
|
70
|
+
.filter((line) => { const key = assignedKey(line); return key && !fresh.has(key); });
|
|
71
|
+
if (kept.length) {
|
|
72
|
+
const end = managed.lastIndexOf(END_MARK);
|
|
73
|
+
managed = `${managed.slice(0, end)}${kept.join('\n')}\n${managed.slice(end)}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const managedKeys = new Set(managed.split('\n').map(assignedKey).filter(Boolean));
|
|
45
77
|
|
|
46
|
-
if (
|
|
47
|
-
const before = text.slice(0, beginIdx).replace(/\s+$/, '');
|
|
48
|
-
const after = text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, '');
|
|
78
|
+
if (hasBlock) {
|
|
79
|
+
const before = withoutKeys(text.slice(0, beginIdx), managedKeys).replace(/\s+$/, '');
|
|
80
|
+
const after = withoutKeys(text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, ''), managedKeys);
|
|
49
81
|
const parts = [];
|
|
50
82
|
if (before) parts.push(before.endsWith('\n') ? before : `${before}\n`);
|
|
51
83
|
parts.push(managed.endsWith('\n') ? managed : `${managed}\n`);
|
|
@@ -56,10 +88,43 @@ export function mergeEnvManagedBlock(existing, managedBody) {
|
|
|
56
88
|
}
|
|
57
89
|
|
|
58
90
|
// No prior TR block — append managed section after existing content
|
|
59
|
-
const
|
|
91
|
+
const rest = withoutKeys(text, managedKeys);
|
|
92
|
+
const base = rest.endsWith('\n') ? rest : `${rest}\n`;
|
|
60
93
|
return `${base}\n${managed}`;
|
|
61
94
|
}
|
|
62
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Refuse a shared plugin path as a secret-projection target.
|
|
98
|
+
*
|
|
99
|
+
* The shared plugin folder lives at `<agentDir>/skills/total-recall/plugins` and
|
|
100
|
+
* normally holds one symlink per plugin pointing at that plugin's owner
|
|
101
|
+
* checkout. A projection written there lands in a plugin repository, which is
|
|
102
|
+
* not an application and must never hold an application's secrets.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} abs absolute, existing directory
|
|
105
|
+
*/
|
|
106
|
+
export function assertNotSharedPluginPath(abs) {
|
|
107
|
+
const agentDir = process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(os.homedir(), '.agent');
|
|
108
|
+
const pluginsRoot = path.join(agentDir, 'skills', 'total-recall', 'plugins');
|
|
109
|
+
const candidates = new Set([path.resolve(abs)]);
|
|
110
|
+
try { candidates.add(fs.realpathSync(abs)); } catch { /* unreadable path */ }
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
if (candidate === pluginsRoot || candidate.startsWith(pluginsRoot + path.sep)) {
|
|
113
|
+
throw new Error(`Refusing to write secrets into the shared plugin folder: ${abs}. Name the application repository with --path.`);
|
|
114
|
+
}
|
|
115
|
+
let entries = [];
|
|
116
|
+
try { entries = fs.readdirSync(pluginsRoot, { withFileTypes: true }); } catch { return; }
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isSymbolicLink()) continue;
|
|
119
|
+
let target;
|
|
120
|
+
try { target = fs.realpathSync(path.join(pluginsRoot, entry.name)); } catch { continue; }
|
|
121
|
+
if (candidate === target || candidate.startsWith(target + path.sep)) {
|
|
122
|
+
throw new Error(`Refusing to write secrets into the plugin checkout ${target} (reached as ${abs}). Name the application repository with --path.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
63
128
|
/**
|
|
64
129
|
* Resolve a project slug from a filesystem path (last segment).
|
|
65
130
|
* @param {string} projectPath
|
|
@@ -172,6 +237,8 @@ ${exampleLines.join('\n')}${exampleLines.length ? '\n' : ''}`;
|
|
|
172
237
|
keys: selected.map((s) => s.key),
|
|
173
238
|
count: selected.length,
|
|
174
239
|
store: resolveSecretsPath(brainDir),
|
|
240
|
+
// [outKey, value] pairs for in-process comparison (remote verify). Never log them.
|
|
241
|
+
entries: selected.map(({ outKey, value }) => [outKey, value]),
|
|
175
242
|
};
|
|
176
243
|
}
|
|
177
244
|
|
|
@@ -194,6 +261,11 @@ export async function exportEnvToProject(brainDir, projectPath, opts = {}) {
|
|
|
194
261
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
195
262
|
throw new Error(`Project path is not a directory: ${abs}`);
|
|
196
263
|
}
|
|
264
|
+
// A secret projection belongs to a repository. The shared plugin folder links
|
|
265
|
+
// every plugin to its owner checkout, so exporting with the default cwd —
|
|
266
|
+
// inside a plugin — wrote a plaintext .env of the whole store into 30 plugin
|
|
267
|
+
// repositories. Refuse that target rather than requiring a flag to avoid it.
|
|
268
|
+
assertNotSharedPluginPath(abs);
|
|
197
269
|
|
|
198
270
|
const filename = opts.filename || '.env';
|
|
199
271
|
const target = {
|