total-recall-brain 3.40.4 → 3.40.6
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/src/cli/init.mjs +73 -2
- package/src/server/keys.mjs +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "total-recall-brain",
|
|
3
|
-
"version": "3.40.
|
|
3
|
+
"version": "3.40.6",
|
|
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": [
|
package/src/cli/init.mjs
CHANGED
|
@@ -198,6 +198,59 @@ function printHelp() {
|
|
|
198
198
|
`);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// ─── Bootstrap token store resolution ───────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The brain directory whose key store the server answering `brainUrl` reads.
|
|
205
|
+
*
|
|
206
|
+
* A server process derives its store from `AGENT_DIR` alone (`core/config.mjs`
|
|
207
|
+
* → `brainDir`), and the launchd service starts it with the GLOBAL agent dir.
|
|
208
|
+
* So a token written into a project brain's `brain.json` is only usable when
|
|
209
|
+
* it was minted into that global store. Minting into the project's own store
|
|
210
|
+
* produced a token no running server would accept, while init still reported
|
|
211
|
+
* success — the failure surfaced later as `startup check` reporting
|
|
212
|
+
* `brain failed` (`instructions-http-401`) for every project.
|
|
213
|
+
*
|
|
214
|
+
* Loopback URLs therefore resolve to the store the local server reads. A
|
|
215
|
+
* remote brain's store is not reachable from here, so the target brain keeps
|
|
216
|
+
* its own store.
|
|
217
|
+
*
|
|
218
|
+
* Two constraints pull against each other and both must hold:
|
|
219
|
+
* 1. `AGENT_DIR` (and the `_TR_TEST_AGENT_DIR` fixture override) honours an
|
|
220
|
+
* explicitly selected root, so the serving store must not be derived by
|
|
221
|
+
* blindly re-reading it — when it is pinned at the project the two stores
|
|
222
|
+
* collapse and the bug returns untouched.
|
|
223
|
+
* 2. An explicit root owns its whole layout: init must never create
|
|
224
|
+
* `$HOME/.agent` behind a caller that named a different root (a test, or
|
|
225
|
+
* an isolated install). So `globalBrainDir` is used only when it is
|
|
226
|
+
* genuinely distinct AND already exists; otherwise the target store is
|
|
227
|
+
* kept rather than a new one being created.
|
|
228
|
+
*/
|
|
229
|
+
export function resolveServingBrainDir({ brainDir, isProject, brainUrl, globalBrainDir = null }) {
|
|
230
|
+
if (!isProject) return brainDir;
|
|
231
|
+
let loopback = false;
|
|
232
|
+
try {
|
|
233
|
+
const host = new URL(brainUrl).hostname;
|
|
234
|
+
loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(host);
|
|
235
|
+
} catch { /* unparseable URL: treat as remote and keep the target store */ }
|
|
236
|
+
if (!loopback) return brainDir;
|
|
237
|
+
// `globalBrainDir` is the caller's explicit answer and is preferred. Only
|
|
238
|
+
// when it names nothing usable do we consider the home-relative global
|
|
239
|
+
// brain — and never when an explicit root owns the layout, because that
|
|
240
|
+
// root's server is the one being served and $HOME belongs to someone else.
|
|
241
|
+
const explicitRoot = Boolean(process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR);
|
|
242
|
+
const candidates = [globalBrainDir];
|
|
243
|
+
if (!explicitRoot) candidates.push(path.join(os.homedir(), '.agent', 'skills', 'total-recall'));
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
if (!candidate || candidate === brainDir) continue;
|
|
246
|
+
// Only adopt a store that is already established; adopting a missing one
|
|
247
|
+
// would make init responsible for creating it, which constraint 2 forbids.
|
|
248
|
+
if (fs.existsSync(path.join(candidate, 'config', 'keys.jsonl'))
|
|
249
|
+
|| fs.existsSync(path.join(candidate, 'config', '.ssss'))) return candidate;
|
|
250
|
+
}
|
|
251
|
+
return brainDir;
|
|
252
|
+
}
|
|
253
|
+
|
|
201
254
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
202
255
|
|
|
203
256
|
export default async function init(args) {
|
|
@@ -207,6 +260,10 @@ export default async function init(args) {
|
|
|
207
260
|
const cwd = process.cwd();
|
|
208
261
|
const globalAgentDir = getGlobalAgentDir();
|
|
209
262
|
const globalBrainDir = path.join(globalAgentDir, 'skills', 'total-recall');
|
|
263
|
+
// The HOME-relative global brain, which is what a background server reads.
|
|
264
|
+
// `globalAgentDir` above honours AGENT_DIR and can therefore point at the
|
|
265
|
+
// project being initialised; the serving store must not be derived from it.
|
|
266
|
+
const servingGlobalBrainDir = path.join(os.homedir(), '.agent', 'skills', 'total-recall');
|
|
210
267
|
|
|
211
268
|
// Determine target based on --project flag
|
|
212
269
|
const isProject = opts.project;
|
|
@@ -705,12 +762,26 @@ export default async function init(args) {
|
|
|
705
762
|
|
|
706
763
|
if (needsBootstrap) {
|
|
707
764
|
try {
|
|
708
|
-
const {
|
|
709
|
-
const keyData = issueKey('Default Local Developer Key', { scopes: ['*'] });
|
|
765
|
+
const { keyStoreFor } = await import('../server/keys.mjs');
|
|
710
766
|
brainCfg.url = brainCfg.url || 'http://localhost:3000';
|
|
767
|
+
// Mint the bootstrap token into the store the SERVER reads, which is
|
|
768
|
+
// the store under `AGENT_DIR` — not necessarily the brain this init run
|
|
769
|
+
// targets. A project brain's token is validated by whatever server
|
|
770
|
+
// process answers `brainCfg.url`, and that process resolves its store
|
|
771
|
+
// from `AGENT_DIR` (launchd starts it with the global one). Issuing
|
|
772
|
+
// into the project's own store wrote a token no running server could
|
|
773
|
+
// match: init reported "pre-authorized" and every request answered 401,
|
|
774
|
+
// so `startup check` said `brain failed` on project after project.
|
|
775
|
+
// When the target IS the store the server reads this is a no-op branch.
|
|
776
|
+
const servingBrainDir = resolveServingBrainDir({ brainDir, isProject, brainUrl: brainCfg.url, globalBrainDir: servingGlobalBrainDir });
|
|
777
|
+
const { issueKey } = keyStoreFor(servingBrainDir);
|
|
778
|
+
const keyData = issueKey('Default Local Developer Key', { scopes: ['*'] });
|
|
711
779
|
brainCfg.token = brainCfg.token || keyData.token;
|
|
712
780
|
writeFileSecure(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
|
|
713
781
|
logOk(`Bootstrap configuration successfully generated and pre-authorized at ${brainCfg.url}`);
|
|
782
|
+
if (isProject && servingBrainDir !== brainDir) {
|
|
783
|
+
logOk(`Bootstrap token issued to the serving brain store at ${servingBrainDir}`);
|
|
784
|
+
}
|
|
714
785
|
if (brainCfg.project_id) {
|
|
715
786
|
logOk(`Assigned project UUID ${brainCfg.project_id} to project brain`);
|
|
716
787
|
}
|
package/src/server/keys.mjs
CHANGED
|
@@ -2,4 +2,30 @@
|
|
|
2
2
|
import { brainDir } from '../core/config.mjs';
|
|
3
3
|
import { loadPluginModule } from '../core/plugin-runtime.mjs';
|
|
4
4
|
const { createKeyStore } = await loadPluginModule('auth', 'access-keys');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The default store is the one under `AGENT_DIR`. That is the store a server
|
|
8
|
+
* process reads, because `core/config.mjs` derives `brainDir` from
|
|
9
|
+
* `AGENT_DIR` alone. Anything that mints a token a *server* must accept has to
|
|
10
|
+
* use this store — see `keyStoreFor`.
|
|
11
|
+
*/
|
|
5
12
|
export const { ALL_SCOPES, KNOWN_SCOPES, normalizeScopes, isExpiredKey, keyHasScope, keyHasAnyScope, loadKeys, issueKey, revokeKey, recordKeyUsage, isValidToken, findValidKeyByToken, loadRoles, setRole, listKeyUsage, recordKeyOutcome } = createKeyStore({ brainDir });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A key store bound to an explicit brain directory.
|
|
16
|
+
*
|
|
17
|
+
* `init` writes a bootstrap PAT into a brain's `config/brain.json`, and the
|
|
18
|
+
* server that answers that brain's URL validates it against whatever store its
|
|
19
|
+
* own `AGENT_DIR` produced. When the two differ, `init` used to mint the token
|
|
20
|
+
* into a store no running server reads: it printed "pre-authorized", and every
|
|
21
|
+
* request with that token then answered 401. Callers that need a token the
|
|
22
|
+
* server will accept resolve the serving brain first and pass it here.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} targetBrainDir absolute brain directory (…/skills/total-recall)
|
|
25
|
+
*/
|
|
26
|
+
export function keyStoreFor(targetBrainDir) {
|
|
27
|
+
if (typeof targetBrainDir !== 'string' || !targetBrainDir) {
|
|
28
|
+
throw new Error('Explicit auth key store brain directory required');
|
|
29
|
+
}
|
|
30
|
+
return createKeyStore({ brainDir: targetBrainDir });
|
|
31
|
+
}
|