total-recall-brain 3.40.4 → 3.40.5
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 +2 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +26 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/agent-login.mjs +127 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-api-rotation.mjs +140 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +31 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-gateway.mjs +21 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +40 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +9 -12
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +6 -6
- 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 +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "total-recall-brain",
|
|
3
|
-
"version": "3.40.
|
|
3
|
+
"version": "3.40.5",
|
|
4
4
|
"description": "The Vibe Coding Brain & Autonomous Software Factory: memory, rules, secrets, and 33 visual plugins automatically managed across all your devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Auth plugin
|
|
2
2
|
|
|
3
|
+
Noninteractive agents use the existing host password carriers in this order: process environment, macOS Keychain, the private TR environment file, then the private `com.totalrecall.brain` LaunchAgent. The LaunchAgent must be an owned regular file at mode 0600 in an owned directory without group/other write permission. `TR_SECRETS_LAUNCH_AGENT` selects an explicit carrier; `TR_SECRETS_NO_HOST_CARRIER=1` disables it. Auth captures the password internally and never copies it into argv, logs or another machine. Missing carriers and decryption failures remain errors.
|
|
4
|
+
|
|
3
5
|
Auth owns encrypted provider credentials, dashboard and collaboration passwords, purpose-bound session signing, passkey storage, and scoped API-key lifecycle. Core adapters require its manifest exports and have no credential implementation fallback.
|
|
4
6
|
|
|
5
7
|
## Operations
|
|
@@ -342,6 +342,31 @@ export async function run(argv = []) {
|
|
|
342
342
|
else throw new Error('Use auth key list|issue <name> --scopes <csv> [--role role] [--expires date]|revoke <id>|usage [id]|roles|role-set <name> --scopes <csv>');
|
|
343
343
|
console.log(JSON.stringify(result)); break;
|
|
344
344
|
}
|
|
345
|
+
case 'agent': {
|
|
346
|
+
// Sign in an agent CLI that authenticates on its own terminal. The pty lives in
|
|
347
|
+
// scripts/agent-login-pty.py because Node has none and this plugin carries no pty library;
|
|
348
|
+
// this routes the plugin's own surface to it.
|
|
349
|
+
const { loginAgent, AGENT_LOGIN_PROFILES } = await import('./src/agent-login.mjs');
|
|
350
|
+
const [action = 'list', name] = rest;
|
|
351
|
+
const flag = (n) => { const at = rest.indexOf(n); return at < 0 ? undefined : rest[at + 1]; };
|
|
352
|
+
if (action === 'list') {
|
|
353
|
+
console.log(JSON.stringify(Object.keys(AGENT_LOGIN_PROFILES).map((id) => ({ id, ...AGENT_LOGIN_PROFILES[id] }))));
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (action !== 'login') throw new Error('Use auth agent list | auth agent login <agent> [--code <code>] [--code-file <path>] [--url-file <path>]');
|
|
357
|
+
const result = await loginAgent({
|
|
358
|
+
agent: name,
|
|
359
|
+
code: flag('--code'),
|
|
360
|
+
codeFile: flag('--code-file'),
|
|
361
|
+
urlFile: flag('--url-file'),
|
|
362
|
+
onEvent: (event) => { if (!isJson && event.event === 'url') console.log('\nOpen this URL and approve it:\n' + event.url + '\n'); },
|
|
363
|
+
});
|
|
364
|
+
if (isJson) console.log(JSON.stringify(result));
|
|
365
|
+
else if (result.ok) console.log(result.alreadySignedIn ? `${name} is already signed in.` : `${name} signed in.`);
|
|
366
|
+
else console.log(`${name} is not signed in. ${result.error || result.reason || ''} ${result.hint || ''}`.trim());
|
|
367
|
+
if (!result.ok) process.exitCode = 2;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
345
370
|
case 'google':
|
|
346
371
|
await handleGoogle(rest, isJson);
|
|
347
372
|
break;
|
|
@@ -370,6 +395,7 @@ export async function run(argv = []) {
|
|
|
370
395
|
console.log(' key list|issue|revoke|usage|roles|role-set Scoped tokens and role policies');
|
|
371
396
|
console.log(' secret Inspect and manage Total Recall secrets store keys');
|
|
372
397
|
console.log(' password set --stdin [--force-reset] Set encrypted dashboard credential');
|
|
398
|
+
console.log(' agent list|login <agent> Sign in an agent CLI that authenticates on its own terminal');
|
|
373
399
|
break;
|
|
374
400
|
default:
|
|
375
401
|
console.error(`Unknown subcommand: ${command}. Run 'total-recall auth --help' for guidance.`);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-auth",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-auth",
|
|
9
|
-
"version": "1.0
|
|
9
|
+
"version": "1.1.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@simplewebauthn/server": "^13.3.2",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"supertest": "^7.2.2",
|
|
21
|
-
"total-recall-brain": "3.40.
|
|
21
|
+
"total-recall-brain": "3.40.4",
|
|
22
22
|
"vitest": "^5.0.3"
|
|
23
23
|
}
|
|
24
24
|
},
|
|
@@ -3669,9 +3669,9 @@
|
|
|
3669
3669
|
}
|
|
3670
3670
|
},
|
|
3671
3671
|
"node_modules/total-recall-brain": {
|
|
3672
|
-
"version": "3.40.
|
|
3673
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
3674
|
-
"integrity": "sha512
|
|
3672
|
+
"version": "3.40.4",
|
|
3673
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
3674
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
3675
3675
|
"dev": true,
|
|
3676
3676
|
"license": "MIT",
|
|
3677
3677
|
"workspaces": [
|
|
@@ -122,6 +122,11 @@
|
|
|
122
122
|
"description": "Inspect active session tokens",
|
|
123
123
|
"effect": "read"
|
|
124
124
|
},
|
|
125
|
+
{
|
|
126
|
+
"name": "agent",
|
|
127
|
+
"description": "Sign in an agent CLI that authenticates on its own terminal",
|
|
128
|
+
"effect": "destructive"
|
|
129
|
+
},
|
|
125
130
|
{
|
|
126
131
|
"name": "secret",
|
|
127
132
|
"description": "Manage and inspect auth secrets in the Total Recall store",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent CLI sign-in, as an auth-plugin capability.
|
|
3
|
+
*
|
|
4
|
+
* Some agent CLIs authenticate on their own terminal — Antigravity (`agy`) prints a Google consent
|
|
5
|
+
* URL and then blocks reading the authorization code. There was no way to do that through the auth
|
|
6
|
+
* plugin, so it was being hand-rolled beside it and the code ended up nowhere.
|
|
7
|
+
*
|
|
8
|
+
* The pty lives in scripts/agent-login-pty.py, owned by this plugin, because Node has no built-in
|
|
9
|
+
* pty and this plugin carries no pty library. This module is the plugin-facing surface: it resolves
|
|
10
|
+
* the agent, runs the helper, relays its events, and reports a result the CLI can act on.
|
|
11
|
+
*
|
|
12
|
+
* It never claims success from the transcript alone — the helper re-runs the CLI's own login probe
|
|
13
|
+
* and reports `ok` only when the session actually exists.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const HELPER = path.join(HERE, '..', 'scripts', 'agent-login-pty.py');
|
|
22
|
+
|
|
23
|
+
/** Only the facts that differ between CLIs are declared. */
|
|
24
|
+
export const AGENT_LOGIN_PROFILES = {
|
|
25
|
+
agy: {
|
|
26
|
+
binary: 'agy',
|
|
27
|
+
consentHost: 'accounts.google.com',
|
|
28
|
+
probeArgs: 'models',
|
|
29
|
+
signedOutPattern: 'please sign in',
|
|
30
|
+
signIn: 'Run `agy` with no arguments, open the URL it prints, and approve with the Google account the agent should use.',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function agentLoginProfile(agent) {
|
|
35
|
+
return AGENT_LOGIN_PROFILES[String(agent || '').trim().toLowerCase()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {string} options.agent
|
|
41
|
+
* @param {string} [options.code] authorization code if already in hand
|
|
42
|
+
* @param {string} [options.codeFile] path polled for the code (unattended flows)
|
|
43
|
+
* @param {string} [options.urlFile] where to write the consent URL
|
|
44
|
+
* @param {number} [options.timeoutMs]
|
|
45
|
+
* @param {(event: object) => void} [options.onEvent]
|
|
46
|
+
*/
|
|
47
|
+
export async function loginAgent({ agent, code, codeFile, urlFile, timeoutMs = 900_000, onEvent = () => {} }) {
|
|
48
|
+
const profile = agentLoginProfile(agent);
|
|
49
|
+
if (!profile) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
agent,
|
|
53
|
+
error: `No sign-in flow is defined for '${agent}'. Known agents: ${Object.keys(AGENT_LOGIN_PROFILES).join(', ') || '(none)'}.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const args = [
|
|
59
|
+
HELPER,
|
|
60
|
+
'--agent', agent,
|
|
61
|
+
'--binary', profile.binary,
|
|
62
|
+
'--consent-host', profile.consentHost,
|
|
63
|
+
'--probe-args', profile.probeArgs,
|
|
64
|
+
'--signed-out-pattern', profile.signedOutPattern,
|
|
65
|
+
'--timeout', String(Math.floor(timeoutMs / 1000)),
|
|
66
|
+
];
|
|
67
|
+
if (code) args.push('--code', code);
|
|
68
|
+
if (codeFile) args.push('--code-file', codeFile);
|
|
69
|
+
if (urlFile) args.push('--url-file', urlFile);
|
|
70
|
+
|
|
71
|
+
const child = spawn('python3', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
72
|
+
let buffer = '';
|
|
73
|
+
let url = '';
|
|
74
|
+
let sawDone = false;
|
|
75
|
+
let probe = null;
|
|
76
|
+
let helperError = '';
|
|
77
|
+
|
|
78
|
+
child.stdout.on('data', (chunk) => {
|
|
79
|
+
buffer += chunk.toString();
|
|
80
|
+
const lines = buffer.split('\n');
|
|
81
|
+
buffer = lines.pop() || '';
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (!line.trim()) continue;
|
|
84
|
+
let event;
|
|
85
|
+
try {
|
|
86
|
+
event = JSON.parse(line);
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
onEvent(event);
|
|
91
|
+
if (event.event === 'url') url = event.url || url;
|
|
92
|
+
if (event.event === 'probe') probe = event.probe || probe;
|
|
93
|
+
if (event.event === 'already_signed_in') probe = event.probe || probe;
|
|
94
|
+
if (event.event === 'done') {
|
|
95
|
+
sawDone = true;
|
|
96
|
+
resolve({
|
|
97
|
+
ok: Boolean(event.ok),
|
|
98
|
+
agent,
|
|
99
|
+
url,
|
|
100
|
+
probe,
|
|
101
|
+
alreadySignedIn: Boolean(probe && probe.signed_in),
|
|
102
|
+
reason: event.reason,
|
|
103
|
+
hint: event.ok ? undefined : profile.signIn,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
child.stderr.on('data', (chunk) => { helperError += chunk.toString(); });
|
|
110
|
+
|
|
111
|
+
child.on('error', (error) => {
|
|
112
|
+
resolve({ ok: false, agent, error: `Could not start the sign-in helper: ${error.message}` });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
child.on('close', (code_) => {
|
|
116
|
+
if (sawDone) return;
|
|
117
|
+
resolve({
|
|
118
|
+
ok: false,
|
|
119
|
+
agent,
|
|
120
|
+
url,
|
|
121
|
+
probe,
|
|
122
|
+
error: helperError.trim() || `The sign-in helper exited (${code_}) without reporting a result.`,
|
|
123
|
+
hint: profile.signIn,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-API rotation.
|
|
3
|
+
*
|
|
4
|
+
* Some providers let an account mint and revoke its own keys over their API, given a credential
|
|
5
|
+
* with management rights. Until this module existed, no such path existed in the plugin at all:
|
|
6
|
+
* rotation-capability classified keys as `provider_api` and described them as "fully automatic",
|
|
7
|
+
* but rotateAuto sent `provider_api` down the browser path with everything else, so a provider
|
|
8
|
+
* that could rotate itself in one request was instead driven through a console login. A
|
|
9
|
+
* management key therefore had no consumer anywhere in the code.
|
|
10
|
+
*
|
|
11
|
+
* Recipes are data: a provider declares the endpoints and which stored secret holds the
|
|
12
|
+
* management credential (provider-catalog.mjs `rotation.api`). Nothing here is provider-specific
|
|
13
|
+
* beyond those declarations, and no secret value is ever logged or returned.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* create: { method: string, path: string, body?: (label: string) => object, value_path: string, id_path?: string, revoke_path?: string },
|
|
19
|
+
* revoke?: { method: string, path: (id: string, hash: string) => string },
|
|
20
|
+
* probe?: string,
|
|
21
|
+
* }} ProviderApiRecipe
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Read a dotted path out of a response body. */
|
|
25
|
+
function pick(obj, dotted) {
|
|
26
|
+
return String(dotted || '').split('.').reduce((acc, part) => (acc == null ? acc : acc[part]), obj);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rotate a key by calling the provider's own key-management API.
|
|
31
|
+
*
|
|
32
|
+
* Order matters and mirrors the supervised browser flow on purpose: mint the new credential, store
|
|
33
|
+
* it, and only then revoke the old one. If revoking happened first and storing failed, the account
|
|
34
|
+
* would be left with a live key nobody holds; this way a failure leaves an extra key rather than
|
|
35
|
+
* none.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} deps
|
|
38
|
+
* @param {string} deps.brainDir
|
|
39
|
+
* @param {string} deps.key stored secret being rotated
|
|
40
|
+
* @param {string} deps.managementKeyName stored secret holding management rights
|
|
41
|
+
* @param {ProviderApiRecipe} deps.recipe
|
|
42
|
+
* @param {string} deps.baseUrl
|
|
43
|
+
* @param {(brain:string, name:string) => Promise<string|null>} deps.readSecret
|
|
44
|
+
* @param {(brain:string, key:string, value:string, opts:object) => Promise<object>} deps.storeSecret
|
|
45
|
+
* @param {(brain:string, name:string) => Promise<object|null>} deps.secretMeta
|
|
46
|
+
* @param {typeof fetch} [deps.fetchImpl]
|
|
47
|
+
*/
|
|
48
|
+
export async function rotateViaProviderApi({
|
|
49
|
+
brainDir, key, managementKeyName, recipe, baseUrl,
|
|
50
|
+
readSecret, storeSecret, secretMeta, recordKeyId, fetchImpl = fetch,
|
|
51
|
+
}) {
|
|
52
|
+
const management = await readSecret(brainDir, managementKeyName);
|
|
53
|
+
if (!management) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: `No management credential in the store: ${managementKeyName}. `
|
|
57
|
+
+ 'This provider rotates itself over its API, so it needs a key with management rights '
|
|
58
|
+
+ 'stored under that name; without one there is nothing to authenticate the request.',
|
|
59
|
+
needs: managementKeyName,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const auth = { Authorization: `Bearer ${management}` };
|
|
64
|
+
const label = `${key.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}`;
|
|
65
|
+
|
|
66
|
+
let created;
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetchImpl(`${baseUrl}${recipe.create.path}`, {
|
|
69
|
+
method: recipe.create.method || 'POST',
|
|
70
|
+
headers: { ...auth, 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify(recipe.create.body ? recipe.create.body(label) : { name: label }),
|
|
72
|
+
});
|
|
73
|
+
const body = await res.json().catch(() => null);
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `Provider refused to mint a key (HTTP ${res.status}). `
|
|
78
|
+
+ 'A credential without management rights gets 401 or 403 here, which is the usual cause.',
|
|
79
|
+
status: res.status,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const value = pick(body, recipe.create.value_path);
|
|
83
|
+
if (!value) return { ok: false, error: `Provider response carried no new key at ${recipe.create.value_path}` };
|
|
84
|
+
const id = recipe.create.id_path ? pick(body, recipe.create.id_path) : null;
|
|
85
|
+
created = { value, id, label };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { ok: false, error: `Could not reach the provider API: ${error.message}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Store first, revoke second.
|
|
91
|
+
let exports;
|
|
92
|
+
try {
|
|
93
|
+
const result = await storeSecret(brainDir, key, created.value, { actor: 'provider-api-rotation' });
|
|
94
|
+
exports = result && result.exports;
|
|
95
|
+
// Keep the provider's id for the key we just minted, so the next rotation can revoke it.
|
|
96
|
+
if (created.id && recordKeyId) await recordKeyId(brainDir, key, created.id);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `Minted a key but could not store it (${error.message}); the old key is untouched `
|
|
101
|
+
+ 'and the new one should be revoked by hand.',
|
|
102
|
+
minted: created.label,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const revoked = await revokeOld({ auth, recipe, created, secretMeta, brainDir, key, fetchImpl, baseUrl });
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
key,
|
|
111
|
+
class: 'provider_api',
|
|
112
|
+
method: 'provider_api',
|
|
113
|
+
label: created.label,
|
|
114
|
+
revoked,
|
|
115
|
+
exports,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Best-effort revoke of the previous credential; never fails the rotation. */
|
|
120
|
+
async function revokeOld({ auth, recipe, created, secretMeta, brainDir, key, fetchImpl, baseUrl }) {
|
|
121
|
+
if (!recipe.revoke) return { attempted: false, reason: 'provider declares no revoke endpoint' };
|
|
122
|
+
const prior = await secretMeta(brainDir, key);
|
|
123
|
+
const priorId = prior?.provider_key_id || prior?.key_id || null;
|
|
124
|
+
if (!priorId) {
|
|
125
|
+
return {
|
|
126
|
+
attempted: false,
|
|
127
|
+
reason: 'the store does not record the previous key id, so the old key cannot be addressed; '
|
|
128
|
+
+ 'revoke it in the provider console',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetchImpl(`${baseUrl}${recipe.revoke.path(priorId, created.id)}`, {
|
|
133
|
+
method: recipe.revoke.method || 'DELETE',
|
|
134
|
+
headers: auth,
|
|
135
|
+
});
|
|
136
|
+
return res.ok ? { attempted: true, revoked: true } : { attempted: true, revoked: false, status: res.status };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { attempted: true, revoked: false, error: error.message };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
* class?: 'provider_api' | 'provider_browser' | 'self_generated' | 'manual',
|
|
16
16
|
* high_risk?: boolean,
|
|
17
17
|
* manual_keys?: string[],
|
|
18
|
+
* // A provider that lets the account mint and revoke its own keys over its API. The management
|
|
19
|
+
* // credential is a separate stored secret; without it the provider cannot rotate itself, which
|
|
20
|
+
* // is what made 'provider_api' a class with no implementation.
|
|
21
|
+
* management_key?: string,
|
|
22
|
+
* api?: {
|
|
23
|
+
* base_url: string,
|
|
24
|
+
* create: { method?: string, path: string, body?: (label: string) => object, value_path: string, id_path?: string },
|
|
25
|
+
* revoke?: { method?: string, path: (id: string, hash: string) => string },
|
|
26
|
+
* },
|
|
18
27
|
* },
|
|
19
28
|
* default_monthly_cap_usd?: number | null,
|
|
20
29
|
* tiers?: { id: string, label: string, monthly_usd?: number | null }[],
|
|
@@ -93,6 +102,28 @@ export const PROVIDER_CATALOG = [
|
|
|
93
102
|
env_keys: ['OPENROUTER_API_KEY'],
|
|
94
103
|
notes: 'OpenAI-compatible base URL https://openrouter.ai/api/v1',
|
|
95
104
|
},
|
|
105
|
+
// OpenRouter issues and revokes keys over its own API, but only to a credential that carries
|
|
106
|
+
// management rights. The inference key the store already holds cannot do it — the API answers
|
|
107
|
+
// is_management_key:false and 401 on GET /api/v1/keys — so the management credential lives
|
|
108
|
+
// under its own name and this declaration says where to find it.
|
|
109
|
+
rotation: {
|
|
110
|
+
class: 'provider_api',
|
|
111
|
+
management_key: 'OPENROUTER_MANAGEMENT_KEY',
|
|
112
|
+
api: {
|
|
113
|
+
base_url: 'https://openrouter.ai/api/v1',
|
|
114
|
+
create: {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
path: '/keys',
|
|
117
|
+
body: (label) => ({ name: label }),
|
|
118
|
+
value_path: 'key',
|
|
119
|
+
id_path: 'data.hash',
|
|
120
|
+
},
|
|
121
|
+
revoke: {
|
|
122
|
+
method: 'DELETE',
|
|
123
|
+
path: (id) => `/keys/${id}`,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
96
127
|
default_monthly_cap_usd: 30,
|
|
97
128
|
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
98
129
|
},
|
|
@@ -10,6 +10,7 @@ import fs from 'fs';
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import crypto from 'crypto';
|
|
12
12
|
import os from 'os';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
13
14
|
import { encryptSecrets, decryptSecrets, encryptSecretsSync, decryptSecretsSync } from './crypto.mjs';
|
|
14
15
|
import { DEFAULT_KEYCHAIN_SERVICE, readKeychainPassword } from './secrets-keychain.mjs';
|
|
15
16
|
import { writeFileSecure, appendFileSecure } from './secure-file.mjs';
|
|
@@ -58,6 +59,7 @@ export function secretsPassword({
|
|
|
58
59
|
env = process.env,
|
|
59
60
|
readKeychain = readTrKeychainPassword,
|
|
60
61
|
readEnvFile = readTrEnvFilePassword,
|
|
62
|
+
readLaunchAgent = readTrLaunchAgentPassword,
|
|
61
63
|
} = {}) {
|
|
62
64
|
const fromEnv = env.TR_SECRETS_PASSWORD || env.TR_MASTER_PASSWORD;
|
|
63
65
|
if (fromEnv) return fromEnv;
|
|
@@ -70,7 +72,25 @@ export function secretsPassword({
|
|
|
70
72
|
}
|
|
71
73
|
if (fromKeychain) return fromKeychain;
|
|
72
74
|
}
|
|
73
|
-
return readEnvFile(env) || null;
|
|
75
|
+
return readEnvFile(env) || readLaunchAgent(env) || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Existing owner-only launchd carrier, also usable by noninteractive agents.
|
|
79
|
+
* No password is copied into another store, environment, argv or output.
|
|
80
|
+
*/
|
|
81
|
+
export function readTrLaunchAgentPassword(env=process.env,{platform=process.platform,exec=spawnSync}={}) {
|
|
82
|
+
if(platform!=='darwin' || env.TR_SECRETS_NO_HOST_CARRIER==='1')return null;
|
|
83
|
+
const home=env.HOME || (env===process.env?os.homedir():null);
|
|
84
|
+
const file=env.TR_SECRETS_LAUNCH_AGENT || (home?path.join(home,'Library/LaunchAgents/com.totalrecall.brain.plist'):null);
|
|
85
|
+
if(!file)return null;
|
|
86
|
+
try {
|
|
87
|
+
const stat=fs.lstatSync(file),directory=fs.lstatSync(path.dirname(file));
|
|
88
|
+
const uid=process.getuid?.();
|
|
89
|
+
if(!stat.isFile() || !directory.isDirectory() || (stat.mode & 0o077)!==0 || (directory.mode & 0o022)!==0 || (uid!==undefined && (stat.uid!==uid || directory.uid!==uid)))return null;
|
|
90
|
+
const result=exec('/usr/bin/plutil',['-extract','EnvironmentVariables.TR_SECRETS_PASSWORD','raw','-o','-',file],{encoding:'utf8',timeout:5000,maxBuffer:65536});
|
|
91
|
+
if(result.status!==0)return null;
|
|
92
|
+
return (result.stdout || '').replace(/\r?\n$/,'') || null;
|
|
93
|
+
}catch{return null;}
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
/**
|
|
@@ -23,6 +23,7 @@ import { getProvider, providerForKeyName } from './provider-catalog.mjs';
|
|
|
23
23
|
import { atomicWrite, safeStringify } from './vault.mjs';
|
|
24
24
|
import { getRotationPlan, generateSecretValue } from './rotation-capability.mjs';
|
|
25
25
|
import { getRecipe, valueLooksValid } from './provider-rotation-recipes.mjs';
|
|
26
|
+
import { rotateViaProviderApi } from './provider-api-rotation.mjs';
|
|
26
27
|
import {
|
|
27
28
|
launchRotationContext,
|
|
28
29
|
openConsole,
|
|
@@ -440,7 +441,45 @@ export async function rotateAuto(brainDir, key, opts = {}) {
|
|
|
440
441
|
return { ok: true, key, class: plan.class, method: 'self_generated', exports: result.exports };
|
|
441
442
|
}
|
|
442
443
|
|
|
443
|
-
|
|
444
|
+
// A provider that can mint and revoke its own keys must not be driven through a browser. This
|
|
445
|
+
// branch used to send provider_api down the browser path with everything else, which is why a
|
|
446
|
+
// management credential had no consumer anywhere in the code and a rotation that needed no human
|
|
447
|
+
// instead waited on a console login.
|
|
448
|
+
const provider = row.provider ? getProvider(row.provider) : providerForKeyName(key);
|
|
449
|
+
const api = provider?.rotation?.api;
|
|
450
|
+
if (plan.class === 'provider_api' && api?.create) {
|
|
451
|
+
const attempt = await rotateViaProviderApi({
|
|
452
|
+
brainDir,
|
|
453
|
+
key,
|
|
454
|
+
managementKeyName: provider.rotation.management_key,
|
|
455
|
+
recipe: api,
|
|
456
|
+
baseUrl: api.base_url,
|
|
457
|
+
// getSecret returns an object even for a key it does not hold, so 'truthy' is not the test.
|
|
458
|
+
// Reading a missing management key as present sent the request out with a bogus credential
|
|
459
|
+
// and surfaced the provider's 401, which reads as "wrong key" when the real answer is
|
|
460
|
+
// "no management key is stored at all".
|
|
461
|
+
readSecret: async (brain, name) => {
|
|
462
|
+
try {
|
|
463
|
+
const found = await getSecret(brain, name);
|
|
464
|
+
if (!found || found.found === false) return null;
|
|
465
|
+
const value = found.value ?? found;
|
|
466
|
+
return typeof value === 'string' && value ? value : null;
|
|
467
|
+
} catch { return null; }
|
|
468
|
+
},
|
|
469
|
+
storeSecret: (brain, name, value, o) => rotateSecretAndExport(brain, name, value, {
|
|
470
|
+
actor: o.actor, exportEnv: opts.exportEnv !== false,
|
|
471
|
+
}),
|
|
472
|
+
secretMeta: async (brain, name) => (await listSecretsMeta(brain)).find((r) => r.key === name) || null,
|
|
473
|
+
// Remember which key the provider minted, so the next rotation can revoke this one instead of
|
|
474
|
+
// reporting that the previous credential cannot be addressed.
|
|
475
|
+
recordKeyId: async (brain, name, id) => {
|
|
476
|
+
try { await updateSecretMeta(brain, name, { provider_key_id: id }); } catch { /* metadata is best-effort */ }
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
return { ...attempt, class: plan.class, method: 'provider_api' };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (plan.class === 'provider_browser') {
|
|
444
483
|
return { ...(await rotateViaBrowser(brainDir, key, opts)), class: plan.class, method: 'browser' };
|
|
445
484
|
}
|
|
446
485
|
|
|
@@ -7,20 +7,17 @@ import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAudit,
|
|
|
7
7
|
|
|
8
8
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
9
9
|
|
|
10
|
-
function serverPort(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// reachable on a different port (the cloud brain serves on 3900, not 3000).
|
|
14
|
-
const candidate = Number(override ?? process.env.TR_SERVER_PORT ?? process.env.PORT ?? 3000);
|
|
15
|
-
return Number.isInteger(candidate) && candidate > 0 && candidate <= 65535 ? candidate : 3000;
|
|
10
|
+
function serverPort() {
|
|
11
|
+
const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
|
|
12
|
+
return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
function meshUrl(ip, route
|
|
15
|
+
function meshUrl(ip, route) {
|
|
19
16
|
const octets = String(ip || '').split('.').map(Number);
|
|
20
17
|
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255) || octets[0] !== 100 || octets[1] < 64 || octets[1] > 127) {
|
|
21
18
|
throw new Error('Leader IP is outside the Tailscale CGNAT range');
|
|
22
19
|
}
|
|
23
|
-
return `http://${ip}:${serverPort(
|
|
20
|
+
return `http://${ip}:${serverPort()}${route}`;
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
export function getSecretsChecksum() {
|
|
@@ -42,8 +39,8 @@ export function secretsSyncMode(env = process.env) {
|
|
|
42
39
|
* anything. The node's store is never replaced wholesale and the node keeps
|
|
43
40
|
* its own master password.
|
|
44
41
|
*/
|
|
45
|
-
export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch
|
|
46
|
-
const base = meshUrl(leaderIp, '/api/secrets/mesh'
|
|
42
|
+
export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch } = {}) {
|
|
43
|
+
const base = meshUrl(leaderIp, '/api/secrets/mesh');
|
|
47
44
|
const listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
48
45
|
if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
|
|
49
46
|
const { keys } = await listResponse.json();
|
|
@@ -79,6 +76,6 @@ export async function syncLoop() {
|
|
|
79
76
|
if (await isLeader()) return;
|
|
80
77
|
const leaderInfo = await getLeaderInfo();
|
|
81
78
|
if (!leaderInfo?.ip) return;
|
|
82
|
-
try { await syncGrantedSecrets(leaderInfo.ip
|
|
83
|
-
catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message
|
|
79
|
+
try { await syncGrantedSecrets(leaderInfo.ip); }
|
|
80
|
+
catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message }); }
|
|
84
81
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-code-quality",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-code-quality",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@gregiteen/ssss-cli": "0.11.2"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"total-recall-brain": "3.40.
|
|
15
|
+
"total-recall-brain": "3.40.4"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"node_modules/@gregiteen/ssss-cli": {
|
|
@@ -2595,9 +2595,9 @@
|
|
|
2595
2595
|
}
|
|
2596
2596
|
},
|
|
2597
2597
|
"node_modules/total-recall-brain": {
|
|
2598
|
-
"version": "3.40.
|
|
2599
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
2600
|
-
"integrity": "sha512
|
|
2598
|
+
"version": "3.40.4",
|
|
2599
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
2600
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
2601
2601
|
"dev": true,
|
|
2602
2602
|
"license": "MIT",
|
|
2603
2603
|
"workspaces": [
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-composable-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-composable-cli",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@gregiteen/ssss-cli": "0.11.2"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"total-recall-brain": "3.40.
|
|
15
|
+
"total-recall-brain": "3.40.4"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"node_modules/@gregiteen/ssss-cli": {
|
|
@@ -2595,9 +2595,9 @@
|
|
|
2595
2595
|
}
|
|
2596
2596
|
},
|
|
2597
2597
|
"node_modules/total-recall-brain": {
|
|
2598
|
-
"version": "3.40.
|
|
2599
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
2600
|
-
"integrity": "sha512
|
|
2598
|
+
"version": "3.40.4",
|
|
2599
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
2600
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
2601
2601
|
"dev": true,
|
|
2602
2602
|
"license": "MIT",
|
|
2603
2603
|
"workspaces": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"version": "0.1.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"total-recall-brain": "3.40.
|
|
12
|
+
"total-recall-brain": "3.40.4"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"node_modules/@hexagon/base64": {
|
|
@@ -2566,9 +2566,9 @@
|
|
|
2566
2566
|
}
|
|
2567
2567
|
},
|
|
2568
2568
|
"node_modules/total-recall-brain": {
|
|
2569
|
-
"version": "3.40.
|
|
2570
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
2571
|
-
"integrity": "sha512
|
|
2569
|
+
"version": "3.40.4",
|
|
2570
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
2571
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
2572
2572
|
"dev": true,
|
|
2573
2573
|
"license": "MIT",
|
|
2574
2574
|
"workspaces": [
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-git-sentinel",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-git-sentinel",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"total-recall-brain": "3.40.
|
|
12
|
+
"total-recall-brain": "3.40.4",
|
|
13
13
|
"vitest": "^4.1.10"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
@@ -3560,9 +3560,9 @@
|
|
|
3560
3560
|
}
|
|
3561
3561
|
},
|
|
3562
3562
|
"node_modules/total-recall-brain": {
|
|
3563
|
-
"version": "3.40.
|
|
3564
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
3565
|
-
"integrity": "sha512
|
|
3563
|
+
"version": "3.40.4",
|
|
3564
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
3565
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
3566
3566
|
"dev": true,
|
|
3567
3567
|
"license": "MIT",
|
|
3568
3568
|
"workspaces": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"version": "0.1.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"total-recall-brain": "3.40.
|
|
12
|
+
"total-recall-brain": "3.40.4"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"node_modules/@hexagon/base64": {
|
|
@@ -2556,9 +2556,9 @@
|
|
|
2556
2556
|
}
|
|
2557
2557
|
},
|
|
2558
2558
|
"node_modules/total-recall-brain": {
|
|
2559
|
-
"version": "3.40.
|
|
2560
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
2561
|
-
"integrity": "sha512
|
|
2559
|
+
"version": "3.40.4",
|
|
2560
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
2561
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
2562
2562
|
"dev": true,
|
|
2563
2563
|
"license": "MIT",
|
|
2564
2564
|
"workspaces": [
|
|
@@ -454,5 +454,5 @@ if (!customElements.get('tr-openwiki-card')) {
|
|
|
454
454
|
customElements.define('tr-openwiki-card', OpenWikiCard);
|
|
455
455
|
}
|
|
456
456
|
if (!customElements.get('openwiki-card')) {
|
|
457
|
-
customElements.define('openwiki-card', OpenWikiCard);
|
|
457
|
+
customElements.define('openwiki-card', class extends OpenWikiCard {});
|
|
458
458
|
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-security",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-security",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"jsdom": "^26.1.0",
|
|
13
|
-
"total-recall-brain": "3.40.
|
|
13
|
+
"total-recall-brain": "3.40.4"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"node_modules/@asamuzakjp/css-color": {
|
|
@@ -2916,9 +2916,9 @@
|
|
|
2916
2916
|
}
|
|
2917
2917
|
},
|
|
2918
2918
|
"node_modules/total-recall-brain": {
|
|
2919
|
-
"version": "3.40.
|
|
2920
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.
|
|
2921
|
-
"integrity": "sha512
|
|
2919
|
+
"version": "3.40.4",
|
|
2920
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.4.tgz",
|
|
2921
|
+
"integrity": "sha512-/9JfUWyzXcgHu3kV/rqsOvclMY25PpfW4jSrunsCrkt2MPDUDfGD+z22AoA/OG/jSHE4ZvD/A9QV4IYE2+O99w==",
|
|
2922
2922
|
"dev": true,
|
|
2923
2923
|
"license": "MIT",
|
|
2924
2924
|
"workspaces": [
|