total-recall-brain 3.43.3 → 3.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/docs/reference/cli-reference.md +3 -1
  2. package/package.json +3 -3
  3. package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
  4. package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
  5. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
  6. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
  7. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
  8. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
  9. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
  10. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
  11. package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
  12. package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
  13. package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
  14. package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
  15. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
  16. package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
  17. package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
  18. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
  19. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +14 -13
  20. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
  21. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
  23. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +18 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
  25. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
  26. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +14 -13
  27. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
  28. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
  29. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +14 -13
  30. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
  31. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +14 -13
  33. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
  34. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
  35. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +14 -13
  36. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +3 -3
  37. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -1
  38. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +14 -13
  39. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
  40. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +38 -5
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +38 -7
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +14 -13
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +3 -3
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +85 -3
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +2 -1
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +12 -1
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +15 -3
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +38 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +42 -11
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +40 -13
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +3 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +1 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +5 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +1 -1
  59. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +27 -11
  60. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
  61. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +322 -40
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +14 -13
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +14 -13
  67. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
  68. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
  69. package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
  70. package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
  71. package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
  72. package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
  73. package/src/cli/connect.mjs +2 -29
  74. package/src/cli/context.mjs +1 -0
  75. package/src/cli/ingest-okf.mjs +4 -4
  76. package/src/cli/release.mjs +4 -1
  77. package/src/cli/update.mjs +4 -1
  78. package/src/core/automatic-brain-updates.mjs +11 -6
  79. package/src/core/brain-updates.mjs +11 -1
  80. package/src/core/context-compactor.mjs +7 -2
  81. package/src/core/context-compiler.mjs +6 -1
  82. package/src/core/context-policy.mjs +7 -3
  83. package/src/core/daemon-control.mjs +22 -0
  84. package/src/core/global-brain-updates.mjs +1 -1
  85. package/src/core/mesh-brain-updates.mjs +2 -1
  86. package/src/core/server-restart.mjs +128 -7
  87. package/src/core/ssss-kernel-bridge.mjs +1 -2
  88. package/src/core/surface.mjs +3 -3
  89. package/src/core/task-context.mjs +9 -8
  90. package/src/core/validated-write.mjs +4 -0
  91. package/src/core/vault-files.mjs +8 -2
  92. package/src/core/vault.mjs +1 -1
  93. package/src/server/index.mjs +3 -1
@@ -4,16 +4,32 @@
4
4
  * A secret that reaches a node (see secrets-sync.mjs) is only useful to people and
5
5
  * agents there once the tool that uses it is signed in. This signs `gh` in from
6
6
  * `github_token`, and wires git to it, so a token rotated once lands on every machine
7
- * without anyone logging in by hand. Nothing here prints or logs the token.
7
+ * without anyone logging in by hand.
8
+ *
9
+ * A login changed on the machine itself (`gh auth refresh -s workflow`) belongs to the operator,
10
+ * and comparing values cannot tell that from a rotation. So each node keeps the pair of tokens it
11
+ * last looked at, as fingerprints. Store token moved: rotation, sign in. `gh` token moved: someone
12
+ * else wrote it, so check both with GitHub first, and overwrite only when nothing would be lost.
13
+ *
14
+ * Nothing here prints or logs a token.
8
15
  */
9
16
  import { execFile } from 'node:child_process';
17
+ import crypto from 'node:crypto';
10
18
 
11
19
  const TIMEOUT_MS = 20_000;
20
+ const HOST = 'github.com';
21
+ const REVOKE_URL = 'https://api.github.com/credentials/revoke';
22
+ const NO_LOGIN = /no oauth token/i;
12
23
 
13
24
  /** Run a command, optionally feeding stdin; never rejects. */
14
- export function defaultRun(cmd, args, input) {
25
+ export function defaultRun(cmd, args, input, env = {}) {
15
26
  return new Promise((resolve) => {
16
- const child = execFile(cmd, args, { timeout: TIMEOUT_MS, env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' } }, (error, stdout, stderr) => {
27
+ const childEnv = { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' };
28
+ // Either of these outranks gh's stored login, so a daemon that inherited one would
29
+ // report it as the login and could not sign in at all.
30
+ delete childEnv.GH_TOKEN;
31
+ delete childEnv.GITHUB_TOKEN;
32
+ const child = execFile(cmd, args, { timeout: TIMEOUT_MS, env: { ...childEnv, ...env } }, (error, stdout, stderr) => {
17
33
  resolve({ ok: !error, code: error?.code ?? 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
18
34
  });
19
35
  child.stdin?.end(input === undefined ? undefined : String(input));
@@ -22,21 +38,149 @@ export function defaultRun(cmd, args, input) {
22
38
 
23
39
  const firstLine = (text) => String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || 'unknown error';
24
40
 
41
+ /** The same value `auth secret list` shortens to its fingerprint. */
42
+ export const tokenFingerprint = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
43
+
44
+ /** What `gh` holds on this machine. The token stays in memory, for the caller to compare. */
45
+ export async function readGhToken(run = defaultRun) {
46
+ const res = await run('gh', ['auth', 'token', '--hostname', HOST]);
47
+ if (res.code === 'ENOENT') return { state: 'gh-missing' };
48
+ if (res.ok && res.stdout.trim()) return { state: 'token', token: res.stdout.trim() };
49
+ if (res.ok || NO_LOGIN.test(res.stderr)) return { state: 'none' };
50
+ // A locked keychain or a timeout is not an empty login: signing in over it could lose one.
51
+ return { state: 'unreadable', error: firstLine(res.stderr) };
52
+ }
53
+
25
54
  /**
26
- * Sign `gh` in with `token` when it is not already, then point git at it.
27
- * @returns {{ status: 'no-token'|'gh-missing'|'current'|'logged-in'|'failed', error?: string }}
55
+ * Ask GitHub about one token, which travels in this child's environment only.
56
+ * `gh api --include` prints the status line even for a 401 and nothing at all when offline,
57
+ * which separates a dead token from a dead network without matching error text.
58
+ * @returns {Promise<{ state: 'ok'|'rejected'|'unknown', scopes: string[], login?: string }>}
28
59
  */
29
- export async function projectGithubLogin({ token, run = defaultRun } = {}) {
60
+ export async function probeGithubToken(token, run = defaultRun) {
61
+ const res = await run('gh', ['api', '--hostname', HOST, '--include', 'user'], undefined, { GH_TOKEN: token });
62
+ const [head] = res.stdout.split(/\r?\n\r?\n/, 1);
63
+ const status = Number(head.match(/^HTTP\/\S+ (\d{3})/)?.[1]);
64
+ if (status === 401) return { state: 'rejected', scopes: [] };
65
+ if (status !== 200) return { state: 'unknown', scopes: [] };
66
+ let login = null;
67
+ try { login = JSON.parse(res.stdout.slice(head.length)).login; } catch { /* reported as unknown below */ }
68
+ if (!login) return { state: 'unknown', scopes: [] };
69
+ const scopes = (head.match(/^x-oauth-scopes:(.*)$/im)?.[1] || '').split(',').map((s) => s.trim()).filter(Boolean).sort();
70
+ return { state: 'ok', login, scopes };
71
+ }
72
+
73
+ /** What a `gh` token can do that the store's cannot, and the reverse. */
74
+ export function compareScopes(gh, store) {
75
+ return {
76
+ added: gh.scopes.filter((scope) => !store.scopes.includes(scope)),
77
+ removed: store.scopes.filter((scope) => !gh.scopes.includes(scope)),
78
+ };
79
+ }
80
+
81
+ /** Sign `gh` in with `token` on stdin, then point git at it. */
82
+ export async function signInGh(token, run = defaultRun) {
83
+ const login = await run('gh', ['auth', 'login', '--hostname', HOST, '--git-protocol', 'https', '--with-token'], token);
84
+ if (!login.ok) return { status: 'failed', error: firstLine(login.stderr).replaceAll(token, '***') };
85
+ await run('gh', ['auth', 'setup-git', '--hostname', HOST]);
86
+ return { status: 'logged-in' };
87
+ }
88
+
89
+ /**
90
+ * Bring `gh` in step with the store, unless that would lose something the operator holds.
91
+ *
92
+ * @param {{ token?: string, record?: { store: string, gh: string }, run?: Function }} options
93
+ * `record` is the pair this node last looked at; pass back the `record` of the result.
94
+ * @returns {Promise<{
95
+ * status: 'no-token'|'gh-missing'|'current'|'logged-in'|'diverged'|'failed',
96
+ * reason?: string, first?: boolean, login?: string, store_login?: string|null,
97
+ * added?: string[], removed?: string[], replaced?: object, record?: object, error?: string,
98
+ * }>} `diverged` left `gh` alone; `first` is true only when this is news, and `record` is
99
+ * missing or unchanged when GitHub could not be asked.
100
+ */
101
+ export async function projectGithubLogin({ token, record, run = defaultRun } = {}) {
30
102
  const wanted = String(token || '').trim();
31
103
  if (!wanted) return { status: 'no-token' };
32
104
 
33
- const current = await run('gh', ['auth', 'token']);
34
- if (current.code === 'ENOENT') return { status: 'gh-missing' };
35
- if (current.ok && current.stdout.trim() === wanted) return { status: 'current' };
105
+ const held = await readGhToken(run);
106
+ if (held.state === 'gh-missing') return { status: 'gh-missing' };
107
+ if (held.state === 'unreadable') return { status: 'failed', error: held.error.replaceAll(wanted, '***') };
36
108
 
37
- const login = await run('gh', ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--with-token'], wanted);
38
- if (!login.ok) return { status: 'failed', error: firstLine(login.stderr).replaceAll(wanted, '***') };
109
+ const storeFp = tokenFingerprint(wanted);
110
+ const inStep = { store: storeFp, gh: storeFp };
111
+ const signIn = async (reason, extra = {}) => {
112
+ const result = await signInGh(wanted, run);
113
+ return result.status === 'logged-in' ? { ...result, reason, ...extra, record: inStep } : result;
114
+ };
39
115
 
40
- await run('gh', ['auth', 'setup-git', '--hostname', 'github.com']);
41
- return { status: 'logged-in' };
116
+ if (held.state === 'none') return signIn('no-login');
117
+ if (held.token === wanted) return { status: 'current', record: inStep };
118
+
119
+ const ghFp = tokenFingerprint(held.token);
120
+ if (record?.gh === ghFp) {
121
+ // Nothing moved: this difference was already reported, so stay quiet and off the network.
122
+ if (record.store === storeFp) return { status: 'diverged', first: false, reason: record.reason, login: record.login, store_login: record.store_login, added: record.added, removed: record.removed, record };
123
+ // Only the store moved. A rotation is deliberate, so it lands, over a local login too.
124
+ return signIn('rotation', record.gh === record.store ? {} : { replaced: { reason: record.reason, login: record.login, added: record.added, removed: record.removed } });
125
+ }
126
+
127
+ const [gh, store] = await Promise.all([probeGithubToken(held.token, run), probeGithubToken(wanted, run)]);
128
+ if (gh.state === 'rejected') return signIn('gh-token-rejected');
129
+ // Offline, or GitHub answered oddly: not knowing is no reason to overwrite. Look again next cycle.
130
+ if (gh.state !== 'ok' || store.state === 'unknown') return { status: 'diverged', first: false, reason: 'unverified', record };
131
+
132
+ const keep = (reason, scopes = { added: [], removed: [] }) => {
133
+ const next = { store: storeFp, gh: ghFp, reason, login: gh.login, store_login: store.login ?? null, ...scopes };
134
+ return { status: 'diverged', first: true, reason, login: next.login, store_login: next.store_login, ...scopes, record: next };
135
+ };
136
+ if (store.state === 'rejected') return keep('store-token-rejected');
137
+ if (gh.login !== store.login) return keep('different-account');
138
+ const scopes = compareScopes(gh, store);
139
+ const sameScopes = !scopes.added.length && !scopes.removed.length;
140
+ // A local grant was removed again: same account, same powers, so nothing to lose.
141
+ if (sameScopes && record && record.gh !== record.store) return signIn('same-scopes');
142
+ // Otherwise someone asked for this new token on purpose, usually to rotate. Keep it.
143
+ return keep(sameScopes ? 'new-token' : 'different-scopes', scopes);
144
+ }
145
+
146
+ /** Read-only comparison for operators. Holds fingerprints only, never a token. */
147
+ export async function inspectGithubLogin({ token, run = defaultRun } = {}) {
148
+ const wanted = String(token || '').trim();
149
+ if (!wanted) return { state: 'no-token' };
150
+ const held = await readGhToken(run);
151
+ if (held.state === 'gh-missing') return { state: 'gh-missing' };
152
+ if (held.state === 'unreadable') return { state: 'unreadable', error: held.error.replaceAll(wanted, '***') };
153
+ const store = { fingerprint: tokenFingerprint(wanted).slice(0, 12), ...(await probeGithubToken(wanted, run)) };
154
+ if (held.state === 'none') return { state: 'no-login', store };
155
+ if (held.token === wanted) return { state: 'same', store };
156
+ const gh = { fingerprint: tokenFingerprint(held.token).slice(0, 12), ...(await probeGithubToken(held.token, run)) };
157
+ // A rejected or unreachable token reports no scopes, which is not the same as holding none.
158
+ return { state: 'diverged', gh, store, ...(gh.state === 'ok' && store.state === 'ok' ? compareScopes(gh, store) : { added: [], removed: [] }) };
159
+ }
160
+
161
+ /**
162
+ * End a token at GitHub. This endpoint refuses authenticated requests, so it goes out without `gh`,
163
+ * and it answers 202 before anything happens: only GitHub rejecting the token afterwards is proof.
164
+ */
165
+ export async function revokeGithubToken(token, {
166
+ run = defaultRun,
167
+ fetchImpl = fetch,
168
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
169
+ attempts = 5,
170
+ waitMs = 2000,
171
+ } = {}) {
172
+ let requestStatus = null;
173
+ try {
174
+ const res = await fetchImpl(REVOKE_URL, {
175
+ method: 'POST',
176
+ headers: { accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'total-recall' },
177
+ body: JSON.stringify({ credentials: [token] }),
178
+ });
179
+ requestStatus = res.status;
180
+ } catch { /* the check below decides */ }
181
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
182
+ if (attempt) await sleep(waitMs);
183
+ if ((await probeGithubToken(token, run)).state === 'rejected') return { revoked: true, request_status: requestStatus };
184
+ }
185
+ return { revoked: false, request_status: requestStatus };
42
186
  }
@@ -3,8 +3,18 @@ import crypto from 'node:crypto';
3
3
  import { getLeaderInfo, isLeader } from './leader-election.mjs';
4
4
  import { logger } from './logger.mjs';
5
5
  import { brainDir } from './config.mjs';
6
- import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAuditAsync, META_KEY } from './secrets-store.mjs';
7
- import { projectGithubLogin } from './login-projections.mjs';
6
+ import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAuditAsync, rotateSecret, META_KEY } from './secrets-store.mjs';
7
+ import {
8
+ compareScopes,
9
+ defaultRun,
10
+ inspectGithubLogin,
11
+ probeGithubToken,
12
+ projectGithubLogin,
13
+ readGhToken,
14
+ revokeGithubToken,
15
+ signInGh,
16
+ tokenFingerprint,
17
+ } from './login-projections.mjs';
8
18
 
9
19
  const REQUEST_TIMEOUT_MS = 10_000;
10
20
  const FETCH_CONCURRENCY = 8;
@@ -33,6 +43,21 @@ export function secretsSyncMode(env = process.env) {
33
43
  return 'mesh-identity';
34
44
  }
35
45
 
46
+ /** The keys the leader says this machine may read. */
47
+ async function listGrantedKeys(base, fetchImpl) {
48
+ let listResponse;
49
+ try {
50
+ listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
51
+ } catch (err) {
52
+ // "fetch failed" alone named neither the address nor the reason.
53
+ throw new Error(`No Total Recall server answered at ${base}: ${err.cause?.code || err.message}`);
54
+ }
55
+ if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
56
+ const { keys } = await listResponse.json();
57
+ if (!Array.isArray(keys)) throw new Error('Invalid grant list');
58
+ return keys;
59
+ }
60
+
36
61
  /**
37
62
  * Grant-based sync: ask the leader which keys this machine may read, fetch
38
63
  * only those, and merge them into this node's own encrypted store. Keys that
@@ -45,16 +70,7 @@ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl =
45
70
  // The leader's port, never this node's: the droplet serves on 3900 while devices
46
71
  // default to 3000, and assuming our own port made every sync fail.
47
72
  const base = meshUrl(leaderIp, '/api/secrets/mesh', port || serverPort());
48
- let listResponse;
49
- try {
50
- listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
51
- } catch (err) {
52
- // "fetch failed" alone named neither the address nor the reason.
53
- throw new Error(`No Total Recall server answered at ${base}: ${err.cause?.code || err.message}`);
54
- }
55
- if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
56
- const { keys } = await listResponse.json();
57
- if (!Array.isArray(keys)) throw new Error('Invalid grant list');
73
+ const keys = await listGrantedKeys(base, fetchImpl);
58
74
 
59
75
  // Each key costs the leader a store decryption plus a mesh round trip (2-3 s from a laptop),
60
76
  // so a few at a time keeps a full sync under a minute instead of several.
@@ -93,15 +109,143 @@ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl =
93
109
  return { granted: keys.length, updated, removed: removed.length };
94
110
  }
95
111
 
96
- /** Sign in the tools that use synced secrets (today: gh from github_token). */
97
- export async function projectLogins({ dir = brainDir, run } = {}) {
98
- const secrets = await loadSecrets(dir);
99
- const github = await projectGithubLogin({ token: secrets.github_token || secrets.GITHUB_TOKEN, run });
100
- if (github.status === 'logged-in') await appendAuditAsync(dir, { action: 'login_projection', key: 'github_token -> gh', actor: 'mesh-sync' });
112
+ const githubKeyName = (secrets) => (secrets.GITHUB_TOKEN && !secrets.github_token ? 'GITHUB_TOKEN' : 'github_token');
113
+
114
+ /** Reloads first: the checks take seconds, and a key saved meanwhile must survive. */
115
+ async function saveGithubRecord(dir, record) {
116
+ const secrets = ensureMeta(await loadSecrets(dir));
117
+ secrets[META_KEY].login_projections = { ...secrets[META_KEY].login_projections, gh: { ...record, at: new Date().toISOString() } };
118
+ await saveSecrets(dir, secrets);
119
+ }
120
+
121
+ /** One line on what a kept `gh` login holds that the store does not. */
122
+ function describeDifference({ reason, login, store_login: storeLogin, added = [], removed = [] }) {
123
+ if (reason === 'store-token-rejected') return 'GitHub rejects the stored token';
124
+ if (reason === 'different-account') return `gh is signed in as ${login}, the stored token belongs to ${storeLogin}`;
125
+ if (reason === 'new-token') return 'a new token with the same scopes';
126
+ return [added.length ? `adds ${added.join(', ')}` : '', removed.length ? `drops ${removed.join(', ')}` : ''].filter(Boolean).join('; ');
127
+ }
128
+
129
+ /** A desktop notice, once per token. Best effort: the audit log is the record. */
130
+ async function alertOperator(run, eventId, title, message) {
131
+ await run('total-recall', ['alerts', 'send', '--title', title, '--message', message, '--channels', 'os', '--severity', 'info', '--source', 'auth', '--event-id', eventId]);
132
+ }
133
+
134
+ async function reportGithubLogin(dir, github, run) {
101
135
  if (github.status === 'failed') logger.error('secrets-sync', 'gh login from github_token failed', { error: github.error });
136
+ if (github.status === 'logged-in') {
137
+ await appendAuditAsync(dir, { action: 'login_projection', key: `github_token -> gh (${github.reason})`, actor: 'mesh-sync' });
138
+ if (github.replaced) {
139
+ const message = `A rotated github_token replaced this machine's own gh login (${describeDifference(github.replaced)}).`;
140
+ logger.warn('secrets-sync', message);
141
+ await alertOperator(run, `gh-login-replaced-${github.record.store.slice(0, 12)}`, 'gh login replaced by rotation', message);
142
+ }
143
+ }
144
+ if (github.status === 'diverged' && github.first) {
145
+ const difference = describeDifference(github);
146
+ await appendAuditAsync(dir, { action: 'login_projection_kept', key: `gh differs from github_token (${difference})`, actor: 'mesh-sync' });
147
+ const message = `gh on this machine differs from github_token (${difference}), so the sync left it alone. See: total-recall auth secret gh-login status`;
148
+ logger.warn('secrets-sync', message);
149
+ await alertOperator(run, `gh-login-kept-${github.record.gh.slice(0, 12)}`, 'gh login kept', message);
150
+ }
151
+ }
152
+
153
+ /** Sign in the tools that use synced secrets (today: gh from github_token). */
154
+ export async function projectLogins({ dir = brainDir, run = defaultRun } = {}) {
155
+ const secrets = ensureMeta(await loadSecrets(dir));
156
+ const record = secrets[META_KEY].login_projections?.gh;
157
+ const github = await projectGithubLogin({ token: secrets.github_token || secrets.GITHUB_TOKEN, record, run });
158
+ if (github.record && (github.record.store !== record?.store || github.record.gh !== record?.gh)) await saveGithubRecord(dir, github.record);
159
+ await reportGithubLogin(dir, github, run);
102
160
  return { github };
103
161
  }
104
162
 
163
+ /** How `gh` on this machine compares with the store. Changes nothing. */
164
+ export async function githubLoginStatus({ dir = brainDir, run = defaultRun } = {}) {
165
+ const secrets = await loadSecrets(dir);
166
+ return inspectGithubLogin({ token: secrets.github_token || secrets.GITHUB_TOKEN, run });
167
+ }
168
+
169
+ /** Give up this machine's own `gh` login and sign in from the store. Plans unless `yes`. */
170
+ export async function useStoreGithubLogin({ dir = brainDir, yes = false, run = defaultRun } = {}) {
171
+ const secrets = await loadSecrets(dir);
172
+ const token = secrets.github_token || secrets.GITHUB_TOKEN;
173
+ if (!token) throw new Error('The store holds no github_token');
174
+ const status = await inspectGithubLogin({ token, run });
175
+ if (status.state === 'same') return { ok: true, changed: false, ...status };
176
+ if (!yes) return { ok: true, planned: true, ...status };
177
+ const result = await signInGh(token, run);
178
+ if (result.status !== 'logged-in') return { ok: false, changed: false, error: result.error };
179
+ const fingerprint = tokenFingerprint(token);
180
+ await saveGithubRecord(dir, { store: fingerprint, gh: fingerprint });
181
+ await appendAuditAsync(dir, { action: 'login_projection', key: 'github_token -> gh (operator)', actor: 'cli' });
182
+ return { ok: true, changed: true, ...status };
183
+ }
184
+
185
+ /**
186
+ * Save this machine's `gh` token as the mesh's `github_token`. This is what widens a credential
187
+ * across the mesh, so it plans unless `yes`. Only the leader's store reaches other nodes, and the
188
+ * next sync would overwrite a follower's own copy, so a follower writes to the leader over the mesh.
189
+ */
190
+ export async function adoptGithubLogin({ dir = brainDir, yes = false, revokeOld = false, run = defaultRun, exec, fetchImpl = fetch, sleep } = {}) {
191
+ const secrets = await loadSecrets(dir);
192
+ const key = githubKeyName(secrets);
193
+ const previous = secrets[key];
194
+ const held = await readGhToken(run);
195
+ if (held.state !== 'token') throw new Error(held.error || 'gh holds no token to adopt. Sign in with gh first; nothing changed.');
196
+ if (!previous) throw new Error(`The store holds no ${key}; nothing changed. Save it with: total-recall auth secret set ${key} --stdin`);
197
+ const fingerprint = tokenFingerprint(held.token);
198
+ if (held.token === previous) return { ok: true, adopted: false, state: 'same', key, fingerprint: fingerprint.slice(0, 12) };
199
+
200
+ const [gh, store] = await Promise.all([probeGithubToken(held.token, run), probeGithubToken(previous, run)]);
201
+ if (gh.state === 'rejected') throw new Error("GitHub rejects gh's token; nothing changed. Sign in with gh again first.");
202
+ if (gh.state !== 'ok' || store.state === 'unknown') throw new Error('Could not check both tokens with GitHub; nothing changed.');
203
+ if (store.state === 'ok' && store.login !== gh.login) throw new Error(`gh is signed in as ${gh.login}, but ${key} belongs to ${store.login}; nothing changed.`);
204
+
205
+ const leader = await isLeader();
206
+ const leaderInfo = leader ? null : await getLeaderInfo();
207
+ if (!leader && !leaderInfo?.ip) throw new Error('No leader found to write to; nothing changed.');
208
+ const plan = {
209
+ key,
210
+ login: gh.login,
211
+ target: leader ? 'this machine' : leaderInfo.ip,
212
+ gh: { fingerprint: fingerprint.slice(0, 12), scopes: gh.scopes },
213
+ store: { fingerprint: tokenFingerprint(previous).slice(0, 12), state: store.state, scopes: store.scopes },
214
+ // A rejected token reports no scopes, which is not the same as holding none.
215
+ ...(store.state === 'ok' ? compareScopes(gh, store) : { added: [], removed: [] }),
216
+ revoke_old: revokeOld,
217
+ };
218
+ if (!yes) return { ok: true, planned: true, ...plan };
219
+
220
+ if (leader) {
221
+ await rotateSecret(dir, key, held.token, { actor: 'gh-login-adopt' });
222
+ } else {
223
+ // Checked first: without a grant the leader would change and this machine would never catch up.
224
+ const granted = await listGrantedKeys(meshUrl(leaderInfo.ip, '/api/secrets/mesh', leaderInfo.port || serverPort()), fetch);
225
+ if (!granted.includes(key)) throw new Error(`This machine holds no grant for ${key}, so it would not receive the token back; nothing changed.`);
226
+ const runOnLeader = exec || (await import('./mesh.mjs')).execMeshCommand;
227
+ const rotated = await runOnLeader(leaderInfo.ip, `total-recall auth secret rotate ${key} --stdin`, { input: held.token, timeoutMs: 60_000 });
228
+ if (!rotated.success) throw new Error(`The leader did not take the token, so nothing changed: ${String(rotated.stderr || 'no detail').split('\n')[0].replaceAll(held.token, '***')}`);
229
+ // Prove it came back through the sync every other node uses.
230
+ await syncGrantedSecrets(leaderInfo.ip, { dir, port: leaderInfo.port });
231
+ if ((await loadSecrets(dir))[key] !== held.token) throw new Error(`The leader took the token, but this machine did not get it back. Check that its grant includes ${key}. The old token was left working.`);
232
+ // The leader runs no sync of its own, so its gh would keep the old token.
233
+ const leaderLogin = await runOnLeader(leaderInfo.ip, 'gh auth login --hostname github.com --git-protocol https --with-token && gh auth setup-git --hostname github.com', { input: held.token, timeoutMs: 60_000 });
234
+ plan.leader_gh = leaderLogin.success ? 'signed-in' : 'failed';
235
+ }
236
+ await saveGithubRecord(dir, { store: fingerprint, gh: fingerprint });
237
+ await appendAuditAsync(dir, { action: 'login_adopted', key: `gh -> ${key}${plan.added.length ? ` (adds ${plan.added.join(', ')})` : ''}`, actor: 'cli' });
238
+
239
+ if (!revokeOld || store.state !== 'ok') return { ok: plan.leader_gh !== 'failed', adopted: true, ...plan };
240
+ // Never end the old token while a machine still signs in with it.
241
+ if (plan.leader_gh === 'failed') return { ok: false, adopted: true, ...plan, revocation: { revoked: false, skipped: "the leader's gh still uses the old token" } };
242
+ const revocation = await revokeGithubToken(previous, { run, fetchImpl, ...(sleep ? { sleep } : {}) });
243
+ await appendAuditAsync(dir, { action: revocation.revoked ? 'revoke' : 'revoke_failed', key: `${key} (previous value)`, actor: 'cli' });
244
+ // GitHub does not say whether this ends only the one token, so check the new one still works.
245
+ revocation.new_token_works = (await probeGithubToken(held.token, run)).state === 'ok';
246
+ return { ok: revocation.revoked && revocation.new_token_works, adopted: true, ...plan, revocation };
247
+ }
248
+
105
249
  export async function syncLoop() {
106
250
  secretsSyncMode();
107
251
  if (!(await isLeader())) {
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "tr-plugin-code-quality",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "tr-plugin-code-quality",
9
- "version": "0.2.5",
9
+ "version": "0.2.6",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@gregiteen/ssss-cli": "0.14.4"
12
+ "@gregiteen/ssss-cli": "0.14.5"
13
13
  },
14
14
  "devDependencies": {
15
- "total-recall-brain": "3.43.2"
15
+ "total-recall-brain": "3.43.3"
16
16
  }
17
17
  },
18
18
  "node_modules/@alcalzone/ansi-tokenize": {
@@ -715,9 +715,9 @@
715
715
  "license": "MIT"
716
716
  },
717
717
  "node_modules/@gregiteen/ssss-cli": {
718
- "version": "0.14.4",
719
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.4.tgz",
720
- "integrity": "sha512-R6JIgPRvS9xXp0xb6BYO3rauv+72r35GIiDrJcvbjPdTSHFZXr8/jRxFOgoF8yTed/5xyGFF3ey3DI9bOjmHVA==",
718
+ "version": "0.14.5",
719
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.5.tgz",
720
+ "integrity": "sha512-qVEYOocnwn97uJ0eDzmuwN06CYsYWr2t3+iKylpisBHX4wlLWk7P8EmFFdBlx7NyKVIZDDeYT164Gd7/T8UGgw==",
721
721
  "license": "ISC",
722
722
  "bin": {
723
723
  "ssss": "scripts/ssss.mjs"
@@ -1580,9 +1580,9 @@
1580
1580
  },
1581
1581
  "node_modules/@ssss/cli": {
1582
1582
  "name": "@gregiteen/ssss-cli",
1583
- "version": "0.14.4",
1584
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.4.tgz",
1585
- "integrity": "sha512-R6JIgPRvS9xXp0xb6BYO3rauv+72r35GIiDrJcvbjPdTSHFZXr8/jRxFOgoF8yTed/5xyGFF3ey3DI9bOjmHVA==",
1583
+ "version": "0.14.5",
1584
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.5.tgz",
1585
+ "integrity": "sha512-qVEYOocnwn97uJ0eDzmuwN06CYsYWr2t3+iKylpisBHX4wlLWk7P8EmFFdBlx7NyKVIZDDeYT164Gd7/T8UGgw==",
1586
1586
  "dev": true,
1587
1587
  "license": "ISC",
1588
1588
  "bin": {
@@ -5543,9 +5543,9 @@
5543
5543
  }
5544
5544
  },
5545
5545
  "node_modules/total-recall-brain": {
5546
- "version": "3.43.2",
5547
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.43.2.tgz",
5548
- "integrity": "sha512-UQ25I1sS/n//iOI/DTdKP0ZT7zqxHYFOIcegOAuTIeGeTMytzTzjawJs/XsmEKkx185G1ZGvskpE/qA5Kz5P5g==",
5546
+ "version": "3.43.3",
5547
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.43.3.tgz",
5548
+ "integrity": "sha512-yjfPN/BQxEEa+iodyFXQov/6fRt1pZg/7RcmgsbpALwBt6Ki3JJjT3tzArBC7NxHQmXgGYyKnCYId6xe/W2rPg==",
5549
5549
  "dev": true,
5550
5550
  "license": "MIT",
5551
5551
  "workspaces": [
@@ -5576,6 +5576,7 @@
5576
5576
  "antigravity": "bin/antigravity.mjs",
5577
5577
  "total-recall": "bin/total-recall.mjs",
5578
5578
  "total-recall-memory": "bin/total-recall-memory.mjs",
5579
+ "total-recall-server-release": "bin/total-recall-server-release.mjs",
5579
5580
  "vibebrain": "bin/total-recall.mjs"
5580
5581
  },
5581
5582
  "optionalDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-code-quality",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Total Recall code-quality plugin: shared gate runner core and per-repo gate configuration.",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@
11
11
  "validate": "ssss conformance --engine"
12
12
  },
13
13
  "dependencies": {
14
- "@gregiteen/ssss-cli": "0.14.4"
14
+ "@gregiteen/ssss-cli": "0.14.5"
15
15
  },
16
16
  "files": [
17
17
  "cli.mjs",
@@ -26,6 +26,6 @@
26
26
  "access": "public"
27
27
  },
28
28
  "overrides": {
29
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.4"
29
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.5"
30
30
  }
31
31
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "code-quality",
4
4
  "name": "Code Quality Gates",
5
- "version": "0.2.5",
5
+ "version": "0.2.6",
6
6
  "description": "Fail-closed deterministic code-quality gates, lint rules, and test compliance reporting.",
7
7
  "author": "Total Recall contributors",
8
8
  "license": "MIT",
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "tr-plugin-composable-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "tr-plugin-composable-cli",
9
- "version": "0.2.4",
9
+ "version": "0.2.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@gregiteen/ssss-cli": "0.14.4"
12
+ "@gregiteen/ssss-cli": "0.14.5"
13
13
  },
14
14
  "devDependencies": {
15
- "total-recall-brain": "3.42.1"
15
+ "total-recall-brain": "3.43.3"
16
16
  }
17
17
  },
18
18
  "node_modules/@alcalzone/ansi-tokenize": {
@@ -715,9 +715,9 @@
715
715
  "license": "MIT"
716
716
  },
717
717
  "node_modules/@gregiteen/ssss-cli": {
718
- "version": "0.14.4",
719
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.4.tgz",
720
- "integrity": "sha512-R6JIgPRvS9xXp0xb6BYO3rauv+72r35GIiDrJcvbjPdTSHFZXr8/jRxFOgoF8yTed/5xyGFF3ey3DI9bOjmHVA==",
718
+ "version": "0.14.5",
719
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.5.tgz",
720
+ "integrity": "sha512-qVEYOocnwn97uJ0eDzmuwN06CYsYWr2t3+iKylpisBHX4wlLWk7P8EmFFdBlx7NyKVIZDDeYT164Gd7/T8UGgw==",
721
721
  "license": "ISC",
722
722
  "bin": {
723
723
  "ssss": "scripts/ssss.mjs"
@@ -1580,9 +1580,9 @@
1580
1580
  },
1581
1581
  "node_modules/@ssss/cli": {
1582
1582
  "name": "@gregiteen/ssss-cli",
1583
- "version": "0.14.4",
1584
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.4.tgz",
1585
- "integrity": "sha512-R6JIgPRvS9xXp0xb6BYO3rauv+72r35GIiDrJcvbjPdTSHFZXr8/jRxFOgoF8yTed/5xyGFF3ey3DI9bOjmHVA==",
1583
+ "version": "0.14.5",
1584
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.5.tgz",
1585
+ "integrity": "sha512-qVEYOocnwn97uJ0eDzmuwN06CYsYWr2t3+iKylpisBHX4wlLWk7P8EmFFdBlx7NyKVIZDDeYT164Gd7/T8UGgw==",
1586
1586
  "dev": true,
1587
1587
  "license": "ISC",
1588
1588
  "bin": {
@@ -5543,9 +5543,9 @@
5543
5543
  }
5544
5544
  },
5545
5545
  "node_modules/total-recall-brain": {
5546
- "version": "3.42.1",
5547
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.42.1.tgz",
5548
- "integrity": "sha512-woH7mdkGwPFYlKPZurhE8L3BPe+cSeIruNGaPOjGZJm/O8C92Bbr1gsdOY0tTLg5QHlgPHcXG1GMtsm9lPDz8w==",
5546
+ "version": "3.43.3",
5547
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.43.3.tgz",
5548
+ "integrity": "sha512-yjfPN/BQxEEa+iodyFXQov/6fRt1pZg/7RcmgsbpALwBt6Ki3JJjT3tzArBC7NxHQmXgGYyKnCYId6xe/W2rPg==",
5549
5549
  "dev": true,
5550
5550
  "license": "MIT",
5551
5551
  "workspaces": [
@@ -5576,6 +5576,7 @@
5576
5576
  "antigravity": "bin/antigravity.mjs",
5577
5577
  "total-recall": "bin/total-recall.mjs",
5578
5578
  "total-recall-memory": "bin/total-recall-memory.mjs",
5579
+ "total-recall-server-release": "bin/total-recall-server-release.mjs",
5579
5580
  "vibebrain": "bin/total-recall.mjs"
5580
5581
  },
5581
5582
  "optionalDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-composable-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Total Recall composable CLI plugin: create, compose, register, test and safely run custom verbs; every command listed in the instruction surfaces.",
6
6
  "scripts": {
@@ -10,7 +10,7 @@
10
10
  "validate": "ssss conformance --engine"
11
11
  },
12
12
  "dependencies": {
13
- "@gregiteen/ssss-cli": "0.14.4"
13
+ "@gregiteen/ssss-cli": "0.14.5"
14
14
  },
15
15
  "license": "MIT",
16
16
  "files": [
@@ -29,6 +29,6 @@
29
29
  "access": "public"
30
30
  },
31
31
  "overrides": {
32
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.4"
32
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.5"
33
33
  }
34
34
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "composable-cli",
4
4
  "name": "Composable CLI",
5
- "version": "0.2.4",
5
+ "version": "0.2.5",
6
6
  "description": "Dynamic agent command composition engine with pipeline registration, versioning, and execution.",
7
7
  "author": "Total Recall contributors",
8
8
  "license": "MIT",