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.
Files changed (21) hide show
  1. package/package.json +1 -1
  2. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +2 -0
  3. package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +26 -0
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +6 -6
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +5 -0
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/src/agent-login.mjs +127 -0
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-api-rotation.mjs +140 -0
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +31 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-gateway.mjs +21 -1
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +40 -1
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +9 -12
  13. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +6 -6
  14. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +6 -6
  15. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  16. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +6 -6
  17. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  18. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +1 -1
  19. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
  20. package/src/cli/init.mjs +73 -2
  21. 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.4",
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.4",
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.4",
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.3",
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.3",
3673
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
3674
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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": [
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "main": "cli.mjs",
7
7
  "scripts": {
8
- "test": "node --test test/auth.test.mjs && vitest run --exclude '**/auth.test.mjs'"
8
+ "test": "node --test test/*.test.mjs"
9
9
  },
10
10
  "keywords": [
11
11
  "total-recall-plugin",
@@ -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
- if (plan.class === 'provider_browser' || plan.class === 'provider_api') {
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(override) {
11
- // The leader's port is whatever it advertised on the mesh. Assuming the local
12
- // default made sync fail with "fetch failed" against a leader that was up and
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, port) {
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(port)}${route}`;
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, port } = {}) {
46
- const base = meshUrl(leaderIp, '/api/secrets/mesh', port);
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, { port: leaderInfo.brain_port }); }
83
- catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message, leader: leaderInfo.ip, port: leaderInfo.brain_port ?? 'default' }); }
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.1",
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.1",
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.3"
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.3",
2599
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
2600
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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.1",
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.1",
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.3"
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.3",
2599
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
2600
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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.3"
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.3",
2570
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
2571
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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.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.1",
9
+ "version": "1.1.2",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
- "total-recall-brain": "3.40.3",
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.3",
3564
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
3565
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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.3"
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.3",
2560
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
2561
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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.1",
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.1",
9
+ "version": "0.3.2",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
12
  "jsdom": "^26.1.0",
13
- "total-recall-brain": "3.40.3"
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.3",
2920
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.3.tgz",
2921
- "integrity": "sha512-E5elOF/OvGAvDdXVDUQaV1xa7hWDOVYmK3z8V+zPNYVEtEoGhJplY1Rtqu76oHezwJDQRIhc4+3HhpvjH5fIww==",
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 { issueKey } = await import('../server/keys.mjs');
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
  }
@@ -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
+ }