total-recall-brain 3.43.3 → 3.45.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.
- package/bin/total-recall.mjs +6 -4
- package/docs/ARCHITECTURE.md +10 -7
- package/docs/reference/cli-reference.md +16 -9
- package/frontend/dist/assets/index-DACh1dhx.js +5613 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/App.tsx +2 -2
- package/frontend/src/pages/PluginsPage.tsx +1 -1
- package/package.json +3 -3
- package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
- package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
- package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
- package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +38 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
- package/scaffold/.agent/skills/total-recall/plugins/automations/BRICK.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/README.md +66 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/adapters.mjs +231 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/capability/index.mjs +16 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/cli.mjs +121 -0
- package/{src/core/plugin-cron.mjs → scaffold/.agent/skills/total-recall/plugins/automations/cron.mjs} +1 -3
- package/scaffold/.agent/skills/total-recall/plugins/automations/inventory.mjs +88 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/package-lock.json +7661 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/package.json +35 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/plugin.json +167 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/scheduler.mjs +246 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/ui/DESIGN.md +42 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/ui/automations.js +2 -0
- package/scaffold/.agent/skills/total-recall/plugins/automations/ui/card.js +273 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/run-core.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +57 -5
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report-data.mjs +40 -14
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report.mjs +15 -40
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/README.md +112 -0
- package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/cli.mjs +135 -23
- package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package-lock.json +17 -16
- package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package.json +5 -4
- package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +161 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/context.mjs +94 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/phase3-spec-support.mjs +50 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/runtime.mjs +40 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/spec-support.mjs +51 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/_support.mjs +195 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/auth.mjs +201 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/bisect.mjs +40 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/cli-meta.mjs +159 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +30 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit.mjs +46 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/fleet.mjs +156 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/force-push.mjs +58 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/group.mjs +119 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/handoff.mjs +59 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/message.mjs +22 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/resolve.mjs +60 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/rules.mjs +81 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/secret-firewall.mjs +66 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/undo.mjs +132 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/why.mjs +16 -0
- package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/card.js +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +59 -10
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +18 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +160 -29
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +231 -16
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +19 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +101 -16
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/control.mjs +145 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +54 -21
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +40 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +68 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +55 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +64 -15
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +29 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/jobs.mjs +84 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +6 -12
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/maintenance.mjs +17 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/mcp-server.mjs +228 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +26 -2
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +6 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +6 -2
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +34 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reviews.mjs +25 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +28 -12
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/claude.mjs +319 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/codex.mjs +387 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/result.mjs +67 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/resume.mjs +373 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/run.mjs +223 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session-jobs.mjs +72 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +16 -12
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +32 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/verify-ssss.mjs +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +597 -70
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +76 -9
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +47 -18
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh-diagnostics.mjs +44 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +120 -113
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +15 -14
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/references/operations.md +1 -1
- package/src/cli/agent.mjs +1 -1
- package/src/cli/command.mjs +41 -2
- package/src/cli/connect.mjs +2 -29
- package/src/cli/context.mjs +1 -0
- package/src/cli/dream.mjs +93 -69
- package/src/cli/harness.mjs +4 -4
- package/src/cli/help.mjs +1 -1
- package/src/cli/ingest-okf.mjs +4 -4
- package/src/cli/init.mjs +0 -25
- package/src/cli/lessons.mjs +51 -0
- package/src/cli/memory-plugin.mjs +1 -1
- package/src/cli/plugin/copies.mjs +64 -14
- package/src/cli/plugin/create.mjs +6 -7
- package/src/cli/plugin/index.mjs +11 -7
- package/src/cli/plugin/install.mjs +10 -15
- package/src/cli/plugin/remove.mjs +1 -1
- package/src/cli/plugin/search.mjs +2 -2
- package/src/cli/preflight.mjs +19 -6
- package/src/cli/proposals.mjs +1 -1
- package/src/cli/recall.mjs +66 -23
- package/src/cli/release.mjs +4 -1
- package/src/cli/remember.mjs +3 -14
- package/src/cli/server.mjs +18 -0
- package/src/cli/startup.mjs +1 -0
- package/src/cli/task.mjs +9 -2
- package/src/cli/update.mjs +10 -3
- package/src/core/access-ledger.mjs +78 -0
- package/src/core/agent-manager.mjs +2 -2
- package/src/core/automatic-brain-updates.mjs +11 -6
- package/src/core/brain-locate.mjs +15 -0
- package/src/core/brain-updates.mjs +42 -12
- package/src/core/claude-hooks.mjs +3 -1
- package/src/core/cleanup-task.mjs +15 -0
- package/src/core/cli-commands.mjs +2 -0
- package/src/core/cli-maintenance.mjs +4 -1
- package/src/core/cli-semantic-index.mjs +23 -3
- package/src/core/context-compactor.mjs +7 -2
- package/src/core/context-compiler.mjs +26 -1
- package/src/core/context-policy.mjs +7 -3
- package/src/core/daemon-control.mjs +22 -0
- package/src/core/daemon-loop.mjs +39 -62
- package/src/core/default-plugins.mjs +1 -1
- package/src/core/dream.mjs +33 -105
- package/src/core/ensure-dependencies.mjs +148 -0
- package/src/core/fast-recall.mjs +2 -2
- package/src/core/field-evaluation.mjs +23 -0
- package/src/core/gate-mode.mjs +1 -1
- package/src/core/global-brain-updates.mjs +1 -1
- package/src/core/harness-hooks.mjs +14 -2
- package/src/core/lesson-hooks.mjs +74 -0
- package/src/core/lesson-memory.mjs +24 -0
- package/src/core/lesson-sessions.mjs +52 -0
- package/src/core/lessons.mjs +96 -0
- package/src/core/mesh-brain-updates.mjs +2 -1
- package/src/core/meta-harness.mjs +2 -2
- package/src/core/optimizer.mjs +66 -165
- package/src/core/plugin-contracts/card.mjs +3 -0
- package/src/core/plugin-loader.mjs +12 -3
- package/src/core/plugin-runtime.mjs +40 -1
- package/src/core/plugin-store.mjs +35 -23
- package/src/core/post-mortem.mjs +52 -36
- package/src/core/preflight-gate.mjs +23 -2
- package/src/core/proposal-applier.mjs +31 -2
- package/src/core/repo-sync.mjs +4 -6
- package/src/core/required-plugins.mjs +26 -4
- package/src/core/rule-length.mjs +23 -0
- package/src/core/runtime.mjs +1 -1
- package/src/core/scheduler.mjs +35 -43
- package/src/core/schema.mjs +1 -2
- package/src/core/search.mjs +4 -3
- package/src/core/server-restart.mjs +128 -7
- package/src/core/server-service.mjs +148 -0
- package/src/core/session-watcher.mjs +19 -1
- package/src/core/skill-gap.mjs +16 -0
- package/src/core/ssss-kernel-bridge.mjs +33 -6
- package/src/core/startup-health.mjs +6 -1
- package/src/core/surface.mjs +68 -57
- package/src/core/task-context.mjs +9 -8
- package/src/core/task-executors.mjs +54 -106
- package/src/core/validated-write.mjs +5 -0
- package/src/core/vault-files.mjs +8 -2
- package/src/core/vault.mjs +1 -1
- package/src/core/vector-field.mjs +134 -192
- package/src/server/index.mjs +3 -1
- package/src/server/invoke-core-child.mjs +24 -0
- package/src/server/invoke-core-operations.mjs +102 -0
- package/src/server/route-manifest.json +0 -56
- package/src/server/routes/brains.mjs +0 -18
- package/src/server/routes/context.mjs +0 -41
- package/src/server/routes/invoke.mjs +26 -1
- package/src/server/routes/ssss.mjs +0 -169
- package/src/server/routes/system.mjs +0 -15
- package/src/server/routes/tasks.mjs +0 -26
- package/src/server/routes/vault.mjs +2 -23
- package/frontend/dist/assets/index-DD4qONvH.js +0 -5607
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +0 -53
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +0 -129
- package/src/core/append-log.mjs +0 -131
- package/src/core/plugin-tasks.mjs +0 -173
- package/templates/onboarding-interview.md +0 -82
- /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/LICENSE +0 -0
- /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/DESIGN.md +0 -0
|
@@ -294,6 +294,8 @@ function printHelp() {
|
|
|
294
294
|
carriers Find shell profiles and /etc files that export the master
|
|
295
295
|
password to every process; --remove-ambient deletes them
|
|
296
296
|
once an owner-only carrier holds the same password.
|
|
297
|
+
scan --stdin Name the keys whose stored values occur in the text on stdin
|
|
298
|
+
(values are never printed; exit 1 when one occurs)
|
|
297
299
|
fingerprint Identify a credential without printing it: length, type
|
|
298
300
|
prefix, fingerprint and the store keys holding the same
|
|
299
301
|
value. --from-clipboard | --stdin; --expect-prefix <p>
|
|
@@ -311,6 +313,13 @@ function printHelp() {
|
|
|
311
313
|
the provider rejects the key; a value another key shares is refused.
|
|
312
314
|
--yes Revoke it
|
|
313
315
|
--json Machine-readable output
|
|
316
|
+
gh-login status Compare this machine's gh login with github_token (no values)
|
|
317
|
+
gh-login adopt Save gh's token as github_token for every machine that holds a grant.
|
|
318
|
+
Shows the scope change unless --yes is given.
|
|
319
|
+
--yes Do it
|
|
320
|
+
--revoke-old Also end the previous token at GitHub, once the new one is in place
|
|
321
|
+
gh-login use-store Give up this machine's own gh login and sign in from github_token.
|
|
322
|
+
Shows the plan unless --yes is given.
|
|
314
323
|
rotation-status Rotation coverage for every key in the vault
|
|
315
324
|
--json Machine-readable output
|
|
316
325
|
browser-logout Delete TR's persistent browser profile (all sessions)
|
|
@@ -429,6 +438,7 @@ function parseArgs(args) {
|
|
|
429
438
|
'rotate-auto',
|
|
430
439
|
'provider-keys',
|
|
431
440
|
'revoke',
|
|
441
|
+
'gh-login',
|
|
432
442
|
'rm',
|
|
433
443
|
'generate',
|
|
434
444
|
'account-sync',
|
|
@@ -629,6 +639,9 @@ function parseArgs(args) {
|
|
|
629
639
|
case '--revoke':
|
|
630
640
|
out.revokeHash = args[++i];
|
|
631
641
|
break;
|
|
642
|
+
case '--revoke-old':
|
|
643
|
+
out.revokeOld = true;
|
|
644
|
+
break;
|
|
632
645
|
case '--yes':
|
|
633
646
|
out.yes = true;
|
|
634
647
|
break;
|
|
@@ -707,7 +720,7 @@ function parseArgs(args) {
|
|
|
707
720
|
out.keys.push(a);
|
|
708
721
|
} else if (!a.startsWith('--') && !out.key) out.key = a;
|
|
709
722
|
else if (!a.startsWith('--') && !out.value) out.value = a;
|
|
710
|
-
else if (a.startsWith('--') && out.command
|
|
723
|
+
else if (a.startsWith('--') && ['revoke', 'gh-login'].includes(out.command)) out.unknownOption = a;
|
|
711
724
|
}
|
|
712
725
|
i++;
|
|
713
726
|
}
|
|
@@ -762,7 +775,7 @@ export default async function secretCli(argv) {
|
|
|
762
775
|
}
|
|
763
776
|
const brainDir = secretsStoreDir();
|
|
764
777
|
|
|
765
|
-
if (opts.stdin && opts.command !== 'fingerprint') {
|
|
778
|
+
if (opts.stdin && opts.command !== 'fingerprint' && opts.command !== 'scan') {
|
|
766
779
|
if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
|
|
767
780
|
console.error('❌ --stdin is only valid with `set`, `rotate`, `rekey`, or `fingerprint`');
|
|
768
781
|
await exitAfterFlush(1);
|
|
@@ -1281,6 +1294,23 @@ export default async function secretCli(argv) {
|
|
|
1281
1294
|
return;
|
|
1282
1295
|
}
|
|
1283
1296
|
|
|
1297
|
+
if (opts.command === 'scan') {
|
|
1298
|
+
// Name every store key whose value occurs in the text on stdin. Values never leave this
|
|
1299
|
+
// process: callers (the git plugin's secret firewall) get key names only. One call per scan.
|
|
1300
|
+
if (!opts.stdin) {
|
|
1301
|
+
console.error('Usage: total-recall auth secret scan --stdin [--json] (exit 1 when a stored value occurs)');
|
|
1302
|
+
await exitAfterFlush(2);
|
|
1303
|
+
}
|
|
1304
|
+
// Diffs and staged files are large; one value check is not, so scan alone gets 64 MB.
|
|
1305
|
+
const text = readAllStdinSync({ maxBytes: 64 * 1024 * 1024 }).toString();
|
|
1306
|
+
const { textContainsSecrets } = await import('./secrets-store.mjs');
|
|
1307
|
+
const result = await textContainsSecrets(brainDir, text);
|
|
1308
|
+
if (opts.json) console.log(JSON.stringify({ ok: !result.leak, leak: result.leak, keys: result.keys, scanned_bytes: Buffer.byteLength(text) }));
|
|
1309
|
+
else console.log(result.leak ? ` ❌ stored values found for: ${result.keys.join(', ')}` : ' ✅ no stored value found');
|
|
1310
|
+
if (result.leak) process.exitCode = 1;
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1284
1314
|
if (opts.command === 'fingerprint') {
|
|
1285
1315
|
// Identify a credential without seeing it: which store keys hold the same
|
|
1286
1316
|
// value, its length and its non-secret type prefix. Lets an operator check
|
|
@@ -1637,6 +1667,12 @@ export default async function secretCli(argv) {
|
|
|
1637
1667
|
return;
|
|
1638
1668
|
}
|
|
1639
1669
|
|
|
1670
|
+
if (opts.command === 'gh-login') {
|
|
1671
|
+
const { runGhLogin } = await import('./cli-gh-login.mjs');
|
|
1672
|
+
if ((await runGhLogin(brainDir, opts)) !== 0) await exitAfterFlush(1);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1640
1676
|
if (opts.command === 'delete' || opts.command === 'rm') {
|
|
1641
1677
|
if (!opts.key) {
|
|
1642
1678
|
console.error('Usage: total-recall auth secret delete <key>');
|
|
@@ -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.
|
|
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
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
|
34
|
-
if (
|
|
35
|
-
if (
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
return { status: '
|
|
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
|
}
|
|
@@ -771,13 +771,24 @@ export function listUsageEvents(brainDir, { limit = 100, key_ref = null, days =
|
|
|
771
771
|
/**
|
|
772
772
|
* True if any secret value appears in text (for surface/openwiki conformance).
|
|
773
773
|
*/
|
|
774
|
+
export function isSettingValue(val) {
|
|
775
|
+
if (/^[a-z]+$/.test(val)) return true;
|
|
776
|
+
if (/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(val)) return true;
|
|
777
|
+
if (/^[^\s@:/]+@([a-z0-9-]+\.)+[a-z]{2,63}$/i.test(val)) return true;
|
|
778
|
+
if (/^https?:\/\/[^\s@/]+(\/[^\s@]*)?$/i.test(val)) return true;
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
|
|
774
782
|
export async function textContainsSecrets(brainDir, text) {
|
|
775
783
|
if (!text) return { leak: false, keys: [] };
|
|
776
784
|
const secrets = await loadSecrets(brainDir);
|
|
777
785
|
const leaked = [];
|
|
778
786
|
for (const [key, val] of Object.entries(secrets)) {
|
|
779
787
|
if (key === META_KEY) continue;
|
|
780
|
-
|
|
788
|
+
// Settings, not credentials: a single lowercase word (DEPLOY_ENV=production), a hostname
|
|
789
|
+
// (MAILCOW_HOST), an email address, or a URL without user info. Matching them would refuse
|
|
790
|
+
// ordinary text. A URL that carries user:password@ is still checked, as is every other value.
|
|
791
|
+
if (typeof val === 'string' && val.length >= 8 && !isSettingValue(val) && text.includes(val)) {
|
|
781
792
|
leaked.push(key);
|
|
782
793
|
}
|
|
783
794
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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())) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Automation source collection
|
|
2
|
+
|
|
3
|
+
Copy `capability/` into your application. Import `collectSources` from `./capability/index.mjs` and supply unique source IDs, an adapter map, an ISO check timestamp and an optional positive record limit.
|
|
4
|
+
|
|
5
|
+
Each adapter returns records or `{records,status,error?}`. Status is `ok`, `partial`, `unsupported`, `disabled`, `error` or `unauthorized`. The collector returns `automations`, independent source `coverage`, and `checked_at`; one failed source does not erase successful evidence. Missing adapters stay unsupported and oversized inventories become partial.
|
|
6
|
+
|
|
7
|
+
Your application owns authorization, data shape, adapter timeouts, execution and persistence. Supply transports and credentials directly to your own adapters. This capability performs no filesystem, network, process or environment access and prints nothing.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Greg Iteen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Automations
|
|
2
|
+
|
|
3
|
+
Inspect automation sources, triggers, observed outcomes and source coverage across the Total Recall mesh. Composable CLI remains the owner of reusable commands; a command definition is labelled as a manual definition until an explicit automation binding exists.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
total-recall automations --help
|
|
7
|
+
total-recall automations list --json
|
|
8
|
+
total-recall automations list --local --source task-queue
|
|
9
|
+
total-recall automations status --node <node>
|
|
10
|
+
total-recall automations show <automation-id>
|
|
11
|
+
total-recall automations cancel <local-task-id>
|
|
12
|
+
total-recall automations dream --all-brains --json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Inventory includes plugin schedules, durable tasks, research queue work, daemon maintenance/watchers, SSSS workflow/trigger declarations, reusable composed commands, and operating-system automation declarations. macOS reads user/system launchd definitions and the current user’s crontab. Linux reads installed and loaded system/user timer units and the current user’s crontab. Commands, environment variables, queued payloads and secret values are excluded from OS summaries.
|
|
16
|
+
|
|
17
|
+
Each source reports its own coverage. A missing integration, unreadable definition, denied peer, offline node or old collector remains explicit. External agent-platform automation providers have no authenticated inventory contract in this runtime and report unsupported; the plugin does not invent their state. Inventory can therefore be partial even when visible automation records are valid.
|
|
18
|
+
|
|
19
|
+
A declared schedule does not prove a running scheduler. Last handled slot is not a successful run. Observed execution outcomes come from source-owned records/events; unavailable telemetry stays unknown. Individual daemon watcher registration is not reported by the current owner, so those records remain unknown even when the daemon itself is running.
|
|
20
|
+
|
|
21
|
+
Selected-node tasks report `selected_node` and `placement_state`. A task without a canonical selection is `unplaced`. Peer metadata without selection evidence stays `unknown`. `last_run` includes the observed slot. `last_handled_outcome` reports the event for the exact stored handled slot. It stays unknown when no matching event exists.
|
|
22
|
+
|
|
23
|
+
Full peer reads use mesh's authenticated command transport with a fixed local inventory command. If that read is unavailable, the plugin requests mesh-authenticated plugin metadata and marks coverage partial. It never sends a mesh-sync token to the PAT-protected plugin-run route.
|
|
24
|
+
|
|
25
|
+
The card and panel use the same CLI contracts. Cancellation requires a pending local durable task and an owner that supports a conditional pending-to-cancelled transition. The plugin checks the saved owner state before reporting success. Older hosts can still inventory automations. Mesh peers use the plugin's local inventory operation and can report incomplete coverage. [Owner acceptance tracker](docs/projects/in-progress/ALL_AUTOMATIONS/ALL_AUTOMATIONS_PROJECT_TRACKER.md)
|
|
26
|
+
|
|
27
|
+
## Scheduled plugin tasks
|
|
28
|
+
|
|
29
|
+
This plugin runs the `tasks` every plugin declares in its manifest. Total Recall core has no scheduler of its own: the daemon starts this plugin's `scheduler` module through `runtime.daemon`, and it ships bundled with Total Recall.
|
|
30
|
+
|
|
31
|
+
- `command` is a subcommand followed by its arguments, so `cleanup --auto --json` runs `cleanup` with `--auto --json`.
|
|
32
|
+
- The timeout is the task's `timeout_ms`, else the subcommand's declared `timeout_ms`, else 60 seconds.
|
|
33
|
+
- Scheduling runs against the global brain in the home folder, so run records and `plugin.task_run` events are where `list` reads them.
|
|
34
|
+
- A failed run logs its reason and the end of its output, and alerts the operator through operator-alerts once per task per day.
|
|
35
|
+
- A plugin switched off runs no tasks; a missed slot within the last day runs once; the first run is the first matching slot after the task is first seen.
|
|
36
|
+
|
|
37
|
+
## Installation and ownership
|
|
38
|
+
|
|
39
|
+
Use `total-recall plugin install <path-to-this-checkout>` for your own Total Recall workflow. Credentials belong to shared auth. The plugin id and CLI command are `automations`. The package name is `tr-plugin-automations`. The GitHub repository name and folder name change separately.
|
|
40
|
+
|
|
41
|
+
For an application you ship, `total-recall plugin vendor <path-to-this-checkout>` selects the declared capability. Its `collectSources` export takes explicit source adapters and configuration. The Total Recall CLI wrapper, scheduler and platform accounts are host dependencies.
|
|
42
|
+
|
|
43
|
+
## Dream and decay
|
|
44
|
+
|
|
45
|
+
Automations owns one dream task at `40 3 * * *` in each node's local time. Its placement is `per-node`. Its command is `dream --all-brains`. Decay is a phase of dream. It has no separate schedule.
|
|
46
|
+
|
|
47
|
+
The plugin calls `total-recall dream --all-brains --json` through `spawnSync` with no shell. It checks core's help for `--all-brains` first. Older core versions can ignore unknown flags. The operation fails before execution if core lacks the flag. It also fails for process errors, signals, invalid output and failed brain results. The JSON response reports `exit_code`, `child_exit_code` and `results`. Core must return a nonempty results array, either directly or in a `results` or `brains` field. The child timeout is 30 minutes. The host timeout is 31 minutes.
|
|
48
|
+
|
|
49
|
+
`list` shows the dream task on each node. It reads the last real outcome from that node's task events. A handled slot stays separate from a real outcome. The cloud daemon must coordinate tasks across the mesh through Automations. This checkout change adds no new transport or deployment. Fleet execution requires a compatible core and an installed plugin on each node.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
`npm test` runs only owner root/UI specs through `vitest.config.mjs`. Injected skill evaluations run independently in their owner and are not Vitest suites. Tests must use isolated/injected state and must not cancel real tasks. Plugin state is derived; persistent source state and controls stay with their owning APIs.
|
|
54
|
+
|
|
55
|
+
## Using this from an agent
|
|
56
|
+
|
|
57
|
+
Send questions about what is scheduled here: tasks, triggers and real outcomes across the mesh. A declared schedule does not prove anything ran, so read the outcome. To create a reusable command, use Composable CLI.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
total-recall automations list --json
|
|
61
|
+
total-recall automations status --node <node>
|
|
62
|
+
total-recall automations show <automation-id>
|
|
63
|
+
total-recall automations cancel <local-task-id>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Raw commands this plugin owns: `crontab -l` and `launchctl list`.
|