total-recall-brain 3.30.2 → 3.31.2
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 +2 -1
- package/frontend/dist/assets/index-BUEZPLim.js +5742 -0
- package/frontend/dist/assets/index-FOJG4jQh.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/package.json +1 -1
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +8 -11
- package/src/cli/config.mjs +2 -1
- package/src/cli/deploy-ui.mjs +5 -4
- package/src/cli/deploy.mjs +3 -2
- package/src/cli/edit.mjs +206 -0
- package/src/cli/help.mjs +1 -1
- package/src/cli/init.mjs +7 -6
- package/src/cli/reset-password.mjs +2 -1
- package/src/cli/uninstall.mjs +2 -1
- package/src/core/project-brain.mjs +2 -1
- package/src/core/secrets-store.mjs +5 -4
- package/src/core/secure-file.mjs +71 -0
- package/src/core/webauthn-store.mjs +2 -1
- package/src/server/auth.mjs +3 -2
- package/src/server/index.mjs +2 -1
- package/src/server/routes/collab.mjs +7 -3
- package/src/server/routes/config.mjs +4 -3
package/frontend/dist/index.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
18
18
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
19
19
|
<title>Total Recall — Portable Memory</title>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-FOJG4jQh.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
package/frontend/package.json
CHANGED
package/package.json
CHANGED
|
@@ -16,7 +16,6 @@ repo_scoped: true
|
|
|
16
16
|
> **Only agent skill in this package.** Implementation helpers live in `modules/` (not skills).
|
|
17
17
|
> Openwiki ships with the brain at `openwiki/`.
|
|
18
18
|
|
|
19
|
-
|
|
20
19
|
Welcome to the master control skill for the **Total Recall Autonomous AI OS**. > [!NOTE]
|
|
21
20
|
> **Total Recall Architecture:**
|
|
22
21
|
> The Total Recall memory kernel runs as a standalone REST service. Agents interact with the memory vault, perform semantic searches, and compile rules directly using lightweight, filesystem-native REST API calls via standard shell `curl` commands.
|
|
@@ -71,7 +70,9 @@ Use the **MESH / CONTROL SERVER** section. Node details are entity fields on
|
|
|
71
70
|
SSSS `mesh_node` documents — never hardcoded hostnames.
|
|
72
71
|
|
|
73
72
|
### 5d. When running tests, typechecks, or builds
|
|
74
|
-
See **TESTS**.
|
|
73
|
+
See **TESTS**. The full suite and the quality gates run only on the Mac Mini —
|
|
74
|
+
never on a laptop or the cloud droplet. A single spec file may run on any mesh node. Builds and installs are fine on any machine; build large apps
|
|
75
|
+
on the Mac Mini.
|
|
75
76
|
|
|
76
77
|
### 6. When troubleshooting connections, port blocks, or sync errors
|
|
77
78
|
* **Trigger**: If the REST server is unreachable, ports are blocked, or the upstream sync tool encounters errors.
|
|
@@ -553,12 +554,6 @@ that dead-ends back at a manual dashboard step.
|
|
|
553
554
|
|
|
554
555
|
---
|
|
555
556
|
|
|
556
|
-
<!-- BEGIN INJECTED MEMORY: do not edit by hand; rebuilt by total-recall surface -->
|
|
557
|
-
<!-- @route: tfidf, generated_at: 2026-05-21T03:34:14.837Z -->
|
|
558
|
-
|
|
559
|
-
<!-- END INJECTED MEMORY -->
|
|
560
|
-
|
|
561
|
-
|
|
562
557
|
---
|
|
563
558
|
|
|
564
559
|
## 🕸️ MESH / CONTROL SERVER (`mesh` subcommand)
|
|
@@ -602,9 +597,11 @@ npx vitest run <path> # one spec file
|
|
|
602
597
|
|
|
603
598
|
Current baseline (3.30.1): **349 spec files / 2024 tests, ~160 s** on the Mac Mini.
|
|
604
599
|
|
|
605
|
-
> **
|
|
606
|
-
> laptop
|
|
607
|
-
>
|
|
600
|
+
> **The full suite and the quality gates run only on the Mac Mini** — never on a
|
|
601
|
+
> laptop or the cloud droplet; they cause slowdowns and OOM kills. A single spec
|
|
602
|
+
> file may run on any mesh node. Builds
|
|
603
|
+
> and installs are fine anywhere; build large apps on the Mac Mini. Sync and run
|
|
604
|
+
> remotely:
|
|
608
605
|
>
|
|
609
606
|
> ```bash
|
|
610
607
|
> REMOTE=build-host; REMOTE_DIR=code/total-recall # your own host and path
|
package/src/cli/config.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import yaml from 'yaml';
|
|
4
4
|
import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
|
|
5
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
5
6
|
|
|
6
7
|
// Pre-defined mapping of known settings
|
|
7
8
|
const KEY_MAP = {
|
|
@@ -224,7 +225,7 @@ export default async function configCommand(args) {
|
|
|
224
225
|
if (!fs.existsSync(configDir)) {
|
|
225
226
|
fs.mkdirSync(configDir, { recursive: true });
|
|
226
227
|
}
|
|
227
|
-
|
|
228
|
+
writeFileSecure(targetPath, yaml.stringify(targetObj), { encoding: 'utf8', mode: 0o600 });
|
|
228
229
|
|
|
229
230
|
console.log(`\n ✅ Success! Config key "${key}" ${layerLabel} set to: ${JSON.stringify(parsedValue)}`);
|
|
230
231
|
console.log(` 📍 Saved in: ${targetPath}\n`);
|
package/src/cli/deploy-ui.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import os from 'node:os';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import bcrypt from 'bcrypt';
|
|
21
21
|
import yaml from 'yaml';
|
|
22
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
22
23
|
import { BCRYPT_COST } from '../server/auth.mjs';
|
|
23
24
|
|
|
24
25
|
|
|
@@ -175,7 +176,7 @@ export function startDeployUI(port = 3001) {
|
|
|
175
176
|
if (opts.cloudRegion) current['cfg-cloud-region'] = opts.cloudRegion;
|
|
176
177
|
if (opts.installGlobal !== undefined) current['installGlobal'] = opts.installGlobal;
|
|
177
178
|
if (opts.projectPaths) current['projectPaths'] = opts.projectPaths;
|
|
178
|
-
|
|
179
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
179
180
|
|
|
180
181
|
} catch (e) {
|
|
181
182
|
console.error('Failed to auto-persist install options on disk:', e);
|
|
@@ -572,7 +573,7 @@ export function startDeployUI(port = 3001) {
|
|
|
572
573
|
}
|
|
573
574
|
|
|
574
575
|
const updated = { ...current, ...data };
|
|
575
|
-
|
|
576
|
+
writeFileSecure(configFile, JSON.stringify(updated, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
576
577
|
syncInstallOptionsFromDisk();
|
|
577
578
|
|
|
578
579
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1114,7 +1115,7 @@ export function startDeployUI(port = 3001) {
|
|
|
1114
1115
|
}
|
|
1115
1116
|
current['cfg-github-token'] = token;
|
|
1116
1117
|
current['backupRepo'] = backupUrl;
|
|
1117
|
-
|
|
1118
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1118
1119
|
|
|
1119
1120
|
log('✅ Encryption keys and tokens written.');
|
|
1120
1121
|
|
|
@@ -1735,7 +1736,7 @@ console.log("REMOTE_PASS_OK");
|
|
|
1735
1736
|
if (_installOptions.dashboardPassword) {
|
|
1736
1737
|
current['cfg-dashboard-password'] = _installOptions.dashboardPassword;
|
|
1737
1738
|
}
|
|
1738
|
-
|
|
1739
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1739
1740
|
} catch (e) {
|
|
1740
1741
|
console.error('Failed to auto-persist remote install options on disk:', e);
|
|
1741
1742
|
}
|
package/src/cli/deploy.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import yaml from 'yaml';
|
|
|
43
43
|
import bcrypt from 'bcrypt';
|
|
44
44
|
import crypto from 'node:crypto';
|
|
45
45
|
import { BCRYPT_COST } from '../server/auth.mjs';
|
|
46
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -407,7 +408,7 @@ function hardenSecurityConfig(dryRun, dashboardPassword = null) {
|
|
|
407
408
|
log(' 🔑 Dashboard credentials left unconfigured. You will set your admin password on first dashboard access.');
|
|
408
409
|
}
|
|
409
410
|
|
|
410
|
-
|
|
411
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
411
412
|
logOk('Security config hardened: HTTPS required, Express bound to localhost, no public health, no legacy local PAT');
|
|
412
413
|
}
|
|
413
414
|
|
|
@@ -1095,7 +1096,7 @@ export default async function deploy(args) {
|
|
|
1095
1096
|
if (wizardOpts.dashboardPassword) {
|
|
1096
1097
|
current['cfg-dashboard-password'] = wizardOpts.dashboardPassword;
|
|
1097
1098
|
}
|
|
1098
|
-
|
|
1099
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1099
1100
|
} catch (e) {
|
|
1100
1101
|
console.error('Failed to auto-persist cloud install options on disk:', e);
|
|
1101
1102
|
}
|
package/src/cli/edit.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { resolveBrainDir, parseLayerFlag, getBothBrains } from './agent-dir.mjs';
|
|
5
|
+
import { writeNodeValidatedAsync } from '../core/validated-write.mjs';
|
|
6
|
+
import { isSafeVaultName } from '../core/vault.mjs';
|
|
7
|
+
|
|
8
|
+
const PRIORITIES = ['absolute', 'high', 'normal', 'low'];
|
|
9
|
+
const MODALITIES = ['must', 'must_not', 'should', 'should_not', 'descriptive', 'preference'];
|
|
10
|
+
const STATUSES = ['active', 'archived', 'deprecated'];
|
|
11
|
+
|
|
12
|
+
function printHelp() {
|
|
13
|
+
console.log(`
|
|
14
|
+
total-recall edit — Change an existing memory node in place (same slug)
|
|
15
|
+
|
|
16
|
+
Usage: total-recall edit <slug> ["<new content>"] [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--title <text> Replace the title
|
|
20
|
+
--importance, -i <1-5> Replace the importance
|
|
21
|
+
--priority, -p <level> absolute | high | normal | low
|
|
22
|
+
--modality, -m <type> must | must_not | should | should_not | descriptive | preference
|
|
23
|
+
--tags, -t <list> Replace the tags (comma-separated)
|
|
24
|
+
--status <status> active | archived | deprecated
|
|
25
|
+
--global Edit the node in the global brain
|
|
26
|
+
--project Edit the node in the project brain
|
|
27
|
+
--no-compile Skip auto-recompilation after the edit
|
|
28
|
+
--help, -h Show this help
|
|
29
|
+
|
|
30
|
+
Pass "-" as the content to read the new body from stdin. Omit the content to
|
|
31
|
+
keep the body and change only the options given.
|
|
32
|
+
|
|
33
|
+
If neither --global nor --project is specified, the node is looked up in the
|
|
34
|
+
project brain first (if one exists), then in the global brain.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
npx total-recall edit anti-patterns-7a7fb6a5 "Never run tests on the MacBook."
|
|
38
|
+
npx total-recall edit my-rule --priority absolute --modality must --global
|
|
39
|
+
cat new-body.md | npx total-recall edit my-fact -
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Path of `<slug>.md` inside any category directory of `vaultDir`, or null. */
|
|
44
|
+
export function findNodeFile(slug, vaultDir) {
|
|
45
|
+
if (!isSafeVaultName(slug) || !fs.existsSync(vaultDir)) return null;
|
|
46
|
+
for (const entry of fs.readdirSync(vaultDir, { withFileTypes: true })) {
|
|
47
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
48
|
+
const filePath = path.join(vaultDir, entry.name, `${slug}.md`);
|
|
49
|
+
if (fs.existsSync(filePath)) return filePath;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse edit arguments into { slug, body, changes, noCompile }.
|
|
56
|
+
* Throws with a user-facing message on invalid input.
|
|
57
|
+
*/
|
|
58
|
+
export function parseEditArgs(args) {
|
|
59
|
+
const result = { slug: null, body: undefined, changes: {}, noCompile: false };
|
|
60
|
+
const positional = [];
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
const next = () => {
|
|
64
|
+
const value = args[++i];
|
|
65
|
+
if (value === undefined) throw new Error(`${arg} requires a value.`);
|
|
66
|
+
return value;
|
|
67
|
+
};
|
|
68
|
+
if (arg === '--no-compile') result.noCompile = true;
|
|
69
|
+
else if (arg === '--title') result.changes.title = next();
|
|
70
|
+
else if (arg === '--importance' || arg === '-i') {
|
|
71
|
+
const value = Number(next());
|
|
72
|
+
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
|
73
|
+
throw new Error('--importance must be an integer from 1 to 5.');
|
|
74
|
+
}
|
|
75
|
+
result.changes.importance = value;
|
|
76
|
+
} else if (arg === '--priority' || arg === '-p') {
|
|
77
|
+
const value = next().toLowerCase();
|
|
78
|
+
if (!PRIORITIES.includes(value)) throw new Error(`--priority must be one of: ${PRIORITIES.join(', ')}.`);
|
|
79
|
+
result.changes.priority = value;
|
|
80
|
+
} else if (arg === '--modality' || arg === '-m') {
|
|
81
|
+
const value = next().toLowerCase();
|
|
82
|
+
if (!MODALITIES.includes(value)) throw new Error(`--modality must be one of: ${MODALITIES.join(', ')}.`);
|
|
83
|
+
result.changes.modality = value;
|
|
84
|
+
} else if (arg === '--tags' || arg === '-t') {
|
|
85
|
+
result.changes.tags = next().split(',').map((t) => t.trim()).filter(Boolean);
|
|
86
|
+
} else if (arg === '--status') {
|
|
87
|
+
const value = next().toLowerCase();
|
|
88
|
+
if (!STATUSES.includes(value)) throw new Error(`--status must be one of: ${STATUSES.join(', ')}.`);
|
|
89
|
+
result.changes.status = value;
|
|
90
|
+
} else if (/^--?[a-z]/i.test(arg)) {
|
|
91
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
92
|
+
} else positional.push(arg);
|
|
93
|
+
}
|
|
94
|
+
if (positional.length > 2) throw new Error('Quote the new content as a single argument.');
|
|
95
|
+
[result.slug, result.body] = positional;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply an edit to a parsed node. Returns the updated node and the list of
|
|
101
|
+
* changed field names. `immutable` follows `priority`, as in `remember`.
|
|
102
|
+
*/
|
|
103
|
+
export function applyEdit(node, { body, changes }, now = new Date().toISOString()) {
|
|
104
|
+
const updated = { ...node };
|
|
105
|
+
const changed = [];
|
|
106
|
+
if (body !== undefined && body.trim() !== (node.body || '').trim()) {
|
|
107
|
+
updated.body = body.trim();
|
|
108
|
+
changed.push('body');
|
|
109
|
+
}
|
|
110
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
111
|
+
if (JSON.stringify(node[key]) === JSON.stringify(value)) continue;
|
|
112
|
+
updated[key] = value;
|
|
113
|
+
changed.push(key);
|
|
114
|
+
}
|
|
115
|
+
if (changed.includes('title') && node.description === node.title) {
|
|
116
|
+
updated.description = updated.title;
|
|
117
|
+
}
|
|
118
|
+
if (changed.includes('priority')) {
|
|
119
|
+
if (updated.priority === 'absolute') updated.immutable = true;
|
|
120
|
+
else delete updated.immutable;
|
|
121
|
+
}
|
|
122
|
+
if (changed.length) updated.updated = now;
|
|
123
|
+
return { node: updated, changed };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function locate(slug, explicitLayer) {
|
|
127
|
+
const layers = explicitLayer === 'auto' ? ['project', 'global'] : [explicitLayer];
|
|
128
|
+
const brains = getBothBrains();
|
|
129
|
+
for (const layer of layers) {
|
|
130
|
+
if (layer === 'project' && !brains.project) continue;
|
|
131
|
+
const vaultDir = path.join(resolveBrainDir(layer), 'memory-vault');
|
|
132
|
+
const filePath = findNodeFile(slug, vaultDir);
|
|
133
|
+
if (filePath) return { layer, vaultDir, filePath };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readStdin() {
|
|
139
|
+
const chunks = [];
|
|
140
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
141
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default async function edit(args) {
|
|
145
|
+
const { layer: explicitLayer, remainingArgs } = parseLayerFlag(args);
|
|
146
|
+
if (!remainingArgs.length || remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
|
|
147
|
+
printHelp();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = parseEditArgs(remainingArgs);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(` ❌ ${err.message}`);
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (parsed.body === '-') parsed.body = await readStdin();
|
|
160
|
+
if (parsed.body !== undefined && !parsed.body.trim()) {
|
|
161
|
+
console.error(' ❌ The new content is empty. Use `total-recall forget` to delete a node.');
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const found = locate(parsed.slug, explicitLayer);
|
|
167
|
+
if (!found) {
|
|
168
|
+
const where = explicitLayer === 'auto' ? 'project or global' : explicitLayer;
|
|
169
|
+
console.error(` ❌ Node "${parsed.slug}" not found in the ${where} vault.`);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { data, content } = matter(fs.readFileSync(found.filePath, 'utf8'));
|
|
175
|
+
const { node, changed } = applyEdit({ ...data, body: content.trim() }, parsed);
|
|
176
|
+
const layerLabel = `[${found.layer}]`;
|
|
177
|
+
const relPath = path.relative(path.dirname(found.vaultDir), found.filePath);
|
|
178
|
+
|
|
179
|
+
if (!changed.length) {
|
|
180
|
+
console.log(` ℹ️ Nothing to change in ${layerLabel} ${relPath}.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = await writeNodeValidatedAsync(node, found.vaultDir);
|
|
185
|
+
if (!result.success) {
|
|
186
|
+
const errors = result.validation?.errors || [result.error || 'unknown validation failure'];
|
|
187
|
+
console.error(` ❌ Validation failed: ${errors.join('; ')}`);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
console.log(` ✅ Updated ${layerLabel} ${relPath} (${changed.join(', ')})`);
|
|
192
|
+
|
|
193
|
+
if (parsed.noCompile) return;
|
|
194
|
+
console.log(' ⏳ Recompiling active memory surfaces and indexes in the background...');
|
|
195
|
+
try {
|
|
196
|
+
const { spawn } = await import('node:child_process');
|
|
197
|
+
const child = spawn(process.argv[0], [process.argv[1], 'compile', `--${found.layer}`], {
|
|
198
|
+
detached: true,
|
|
199
|
+
stdio: 'ignore',
|
|
200
|
+
});
|
|
201
|
+
child.unref();
|
|
202
|
+
console.log(' ✅ Background compilation started.');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.warn(` ⚠️ Node updated, but background recompilation spawn failed: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/cli/help.mjs
CHANGED
|
@@ -146,7 +146,7 @@ function printGeneralHelp() {
|
|
|
146
146
|
npx total-recall help architecture
|
|
147
147
|
|
|
148
148
|
\x1b[1mCore Memory Operations:\x1b[0m
|
|
149
|
-
\x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mforget\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mmap\x1b[0m
|
|
149
|
+
\x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mforget\x1b[0m, \x1b[33medit\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mmap\x1b[0m
|
|
150
150
|
|
|
151
151
|
\x1b[1mCompilation & System Rules:\x1b[0m
|
|
152
152
|
\x1b[33mcompile\x1b[0m, \x1b[33mrebuild\x1b[0m, \x1b[33mconnect\x1b[0m, \x1b[33mskill\x1b[0m, \x1b[33mimport-rules\x1b[0m
|
package/src/cli/init.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import os from 'node:os';
|
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
32
|
import { exec, spawn, spawnSync } from 'node:child_process';
|
|
33
33
|
import { projectSkillsForScope, detectActiveSkillTargets } from './skill-projection.mjs';
|
|
34
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
34
35
|
|
|
35
36
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const ROOT = path.join(__dirname, '..', '..');
|
|
@@ -408,10 +409,10 @@ export default async function init(args) {
|
|
|
408
409
|
}
|
|
409
410
|
|
|
410
411
|
if (!config.dashboard.password_hash) {
|
|
411
|
-
|
|
412
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: "utf8", mode: 0o600 });
|
|
412
413
|
passwordMessage = "\n 🔑 Dashboard Access: Unconfigured\n (You will be prompted to set your password upon first browser access)";
|
|
413
414
|
} else {
|
|
414
|
-
|
|
415
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
415
416
|
passwordMessage = '\n 🔑 Dashboard credentials preserved (custom password hash already configured)';
|
|
416
417
|
}
|
|
417
418
|
} catch (err) {
|
|
@@ -586,7 +587,7 @@ export default async function init(args) {
|
|
|
586
587
|
current['cfg-health-url'] = 'http://localhost:3000/health';
|
|
587
588
|
}
|
|
588
589
|
|
|
589
|
-
|
|
590
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
590
591
|
logOk('UI deploy configuration persisted to wizard-config.json!');
|
|
591
592
|
}
|
|
592
593
|
|
|
@@ -670,7 +671,7 @@ export default async function init(args) {
|
|
|
670
671
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
671
672
|
const brainCfg = { url: opts.brain };
|
|
672
673
|
if (opts.token) brainCfg.token = opts.token;
|
|
673
|
-
|
|
674
|
+
writeFileSecure(path.join(cfgDir, 'brain.json'), JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
|
|
674
675
|
logOk(`Registered brain at ${opts.brain}. Run \`npx total-recall sync\` to pull instructions.`);
|
|
675
676
|
} else {
|
|
676
677
|
// Zero-config bootstrap: auto-generate localhost brain.json and valid Developer PAT out-of-the-box
|
|
@@ -696,7 +697,7 @@ export default async function init(args) {
|
|
|
696
697
|
const keyData = issueKey('Default Local Developer Key', { scopes: ['*'] });
|
|
697
698
|
brainCfg.url = brainCfg.url || 'http://localhost:3000';
|
|
698
699
|
brainCfg.token = brainCfg.token || keyData.token;
|
|
699
|
-
|
|
700
|
+
writeFileSecure(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
|
|
700
701
|
logOk(`Bootstrap configuration successfully generated and pre-authorized at ${brainCfg.url}`);
|
|
701
702
|
} catch (err) {
|
|
702
703
|
logWarn(`Could not auto-generate bootstrap developer key: ${err.message}`);
|
|
@@ -786,7 +787,7 @@ export default async function init(args) {
|
|
|
786
787
|
current['cfg-dash-url'] = `${tunnelUrl}/`;
|
|
787
788
|
current['cfg-health-url'] = `${tunnelUrl}/health`;
|
|
788
789
|
|
|
789
|
-
|
|
790
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
790
791
|
logOk('Registered Quick Tunnel URL in wizard-config.json!');
|
|
791
792
|
dashboardUrl = `${tunnelUrl}/`;
|
|
792
793
|
} else {
|
|
@@ -5,6 +5,7 @@ import bcrypt from 'bcrypt';
|
|
|
5
5
|
import yaml from 'yaml';
|
|
6
6
|
import { BCRYPT_COST } from '../server/auth.mjs';
|
|
7
7
|
import { brainDir } from '../core/config.mjs';
|
|
8
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Read a password without echoing it, and without it ever becoming an argv
|
|
@@ -128,7 +129,7 @@ export default async function resetPassword(args) {
|
|
|
128
129
|
const isDefault = password === 'totalrecall';
|
|
129
130
|
config.dashboard.force_password_reset = isDefault;
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
132
133
|
|
|
133
134
|
// Never echo the password back. The operator just typed it; repeating it to
|
|
134
135
|
// stdout puts it into scrollback, into any log capturing this command, and
|
package/src/cli/uninstall.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import os from 'node:os';
|
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
32
32
|
import backup from './backup.mjs';
|
|
33
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
33
34
|
|
|
34
35
|
// ╔══════════════════════════════════════════════════════════════════════════╗
|
|
35
36
|
// ║ FORBIDDEN FLAGS — These patterns must NEVER be accepted as arguments ║
|
|
@@ -531,7 +532,7 @@ export default async function uninstall(args = []) {
|
|
|
531
532
|
|
|
532
533
|
if (Object.keys(secretsObj).length > 0) {
|
|
533
534
|
fs.mkdirSync(agentDir, { recursive: true });
|
|
534
|
-
|
|
535
|
+
writeFileSecure(
|
|
535
536
|
path.join(agentDir, 'secrets.enc'),
|
|
536
537
|
JSON.stringify(secretsObj, null, 2),
|
|
537
538
|
{ encoding: 'utf8', mode: 0o600 }
|
|
@@ -19,6 +19,7 @@ import fs from 'fs';
|
|
|
19
19
|
import path from 'path';
|
|
20
20
|
import { fileURLToPath } from 'url';
|
|
21
21
|
import YAML from 'yaml';
|
|
22
|
+
import { writeFileSecure } from './secure-file.mjs';
|
|
22
23
|
|
|
23
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
const ROOT = path.resolve(__dirname, '../..');
|
|
@@ -162,7 +163,7 @@ export function writeBrainIdentity(brainDir, { name, role = 'project', tags = []
|
|
|
162
163
|
updated_at: new Date().toISOString(),
|
|
163
164
|
created_at: current.created_at || new Date().toISOString(),
|
|
164
165
|
};
|
|
165
|
-
|
|
166
|
+
writeFileSecure(brainJsonPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
166
167
|
return next;
|
|
167
168
|
}
|
|
168
169
|
|
|
@@ -13,6 +13,7 @@ import os from 'os';
|
|
|
13
13
|
import YAML from 'yaml';
|
|
14
14
|
import { encryptSecrets, decryptSecrets, encryptSecretsSync, decryptSecretsSync } from './crypto.mjs';
|
|
15
15
|
import { DEFAULT_KEYCHAIN_SERVICE, readKeychainPassword } from './secrets-keychain.mjs';
|
|
16
|
+
import { writeFileSecure, appendFileSecure } from './secure-file.mjs';
|
|
16
17
|
|
|
17
18
|
const META_KEY = '__tr_secrets_meta';
|
|
18
19
|
|
|
@@ -170,7 +171,7 @@ export function saveSecretsSync(brainDir, obj) {
|
|
|
170
171
|
const password = secretsPassword();
|
|
171
172
|
if (!password) throw new Error('TR_SECRETS_PASSWORD or TR_MASTER_PASSWORD is required to write secrets');
|
|
172
173
|
const buf = encryptSecretsSync(obj, password);
|
|
173
|
-
|
|
174
|
+
writeFileSecure(filePath, buf, { mode: 0o600 });
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
/**
|
|
@@ -221,7 +222,7 @@ export async function saveSecrets(brainDir, secrets) {
|
|
|
221
222
|
|
|
222
223
|
if (!password) throw new Error('TR_SECRETS_PASSWORD or TR_MASTER_PASSWORD is required to write secrets');
|
|
223
224
|
const buf = await encryptSecrets(secrets, password);
|
|
224
|
-
|
|
225
|
+
writeFileSecure(filePath, buf, { mode: 0o600 });
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
/**
|
|
@@ -314,7 +315,7 @@ function appendAudit(brainDir, event) {
|
|
|
314
315
|
...event,
|
|
315
316
|
// never include values
|
|
316
317
|
});
|
|
317
|
-
|
|
318
|
+
appendFileSecure(auditPath, line + '\n', { mode: 0o600 });
|
|
318
319
|
}
|
|
319
320
|
|
|
320
321
|
/**
|
|
@@ -983,7 +984,7 @@ export function recordUsage(brainDir, event) {
|
|
|
983
984
|
source: event.source || 'cli',
|
|
984
985
|
key_ref: event.key_ref || null, // secret *name* only, never value
|
|
985
986
|
};
|
|
986
|
-
|
|
987
|
+
appendFileSecure(usagePath, JSON.stringify(row) + '\n', { mode: 0o600 });
|
|
987
988
|
return row;
|
|
988
989
|
}
|
|
989
990
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure file writes for credential-bearing files.
|
|
3
|
+
*
|
|
4
|
+
* `fs.writeFileSync`/`fs.appendFileSync` only apply their `mode` option when the
|
|
5
|
+
* file is CREATED. Rewriting a file that already exists silently preserves the
|
|
6
|
+
* old permissions, so a security.yml or secrets.enc that was ever created under
|
|
7
|
+
* a loose umask (or restored from a backup, or copied by a sync) stays
|
|
8
|
+
* world-readable forever no matter how many times it is rewritten with
|
|
9
|
+
* `{ mode: 0o600 }`.
|
|
10
|
+
*
|
|
11
|
+
* Observed live: config/security.yml at mode 644 while holding the dashboard
|
|
12
|
+
* bcrypt password_hash.
|
|
13
|
+
*
|
|
14
|
+
* Every writer of a file containing hashes, tokens, or key material must go
|
|
15
|
+
* through here so the mode is re-asserted on every write, not just the first.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
|
|
20
|
+
export const SECRET_FILE_MODE = 0o600;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Re-assert permissions on an existing file.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} filePath
|
|
26
|
+
* @param {number} [mode]
|
|
27
|
+
* @returns {boolean} true when the mode was applied
|
|
28
|
+
*/
|
|
29
|
+
export function chmodSecure(filePath, mode = SECRET_FILE_MODE) {
|
|
30
|
+
try {
|
|
31
|
+
fs.chmodSync(filePath, mode);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
// Best effort: chmod can fail on non-POSIX filesystems.
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireSecureMode(filePath, mode) {
|
|
40
|
+
if (!chmodSecure(filePath, mode)) {
|
|
41
|
+
throw new Error(`Could not secure file permissions: ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* writeFileSync that actually enforces `mode` on rewrite.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} filePath
|
|
49
|
+
* @param {string|Buffer} data
|
|
50
|
+
* @param {object} [options] same as fs.writeFileSync; `mode` defaults to 0o600
|
|
51
|
+
*/
|
|
52
|
+
export function writeFileSecure(filePath, data, options = {}) {
|
|
53
|
+
const { mode = SECRET_FILE_MODE, ...rest } = options;
|
|
54
|
+
if (fs.existsSync(filePath)) requireSecureMode(filePath, SECRET_FILE_MODE);
|
|
55
|
+
fs.writeFileSync(filePath, data, { ...rest, mode });
|
|
56
|
+
requireSecureMode(filePath, mode);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* appendFileSync that actually enforces `mode` on an existing file.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} filePath
|
|
63
|
+
* @param {string|Buffer} data
|
|
64
|
+
* @param {object} [options] same as fs.appendFileSync; `mode` defaults to 0o600
|
|
65
|
+
*/
|
|
66
|
+
export function appendFileSecure(filePath, data, options = {}) {
|
|
67
|
+
const { mode = SECRET_FILE_MODE, ...rest } = options;
|
|
68
|
+
if (fs.existsSync(filePath)) requireSecureMode(filePath, SECRET_FILE_MODE);
|
|
69
|
+
fs.appendFileSync(filePath, data, { ...rest, mode });
|
|
70
|
+
requireSecureMode(filePath, mode);
|
|
71
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
|
+
import { writeFileSecure } from './secure-file.mjs';
|
|
12
13
|
import {
|
|
13
14
|
generateRegistrationOptions,
|
|
14
15
|
verifyRegistrationResponse,
|
|
@@ -49,7 +50,7 @@ function loadStore(brainDir) {
|
|
|
49
50
|
function saveStore(brainDir, store) {
|
|
50
51
|
const filePath = resolveWebAuthnPath(brainDir);
|
|
51
52
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
52
|
-
|
|
53
|
+
writeFileSecure(filePath, JSON.stringify(store, null, 2) + '\n', {
|
|
53
54
|
encoding: 'utf8',
|
|
54
55
|
mode: 0o600,
|
|
55
56
|
});
|
package/src/server/auth.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { findValidKeyByToken, keyHasAnyScope, recordKeyUsage } from './keys.mjs'
|
|
|
11
11
|
import { logger } from '../core/logger.mjs';
|
|
12
12
|
|
|
13
13
|
import { agentDir, brainDir, sessionSecret, nodeEnv } from '../core/config.mjs';
|
|
14
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
14
15
|
|
|
15
16
|
const BRAIN_DIR = brainDir;
|
|
16
17
|
const CONFIG_FILE = path.join(BRAIN_DIR, 'config', 'security.yml');
|
|
@@ -26,7 +27,7 @@ try {
|
|
|
26
27
|
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
|
27
28
|
try {
|
|
28
29
|
fs.mkdirSync(path.dirname(JWT_SECRET_PATH), { recursive: true });
|
|
29
|
-
|
|
30
|
+
writeFileSecure(JWT_SECRET_PATH, JWT_SECRET, { mode: 0o600 });
|
|
30
31
|
} catch { /* non-fatal — will regenerate on next restart */ }
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -450,7 +451,7 @@ export async function changePasswordHandler(req, res) {
|
|
|
450
451
|
config.dashboard.password_hash = hash;
|
|
451
452
|
config.dashboard.force_password_reset = false;
|
|
452
453
|
|
|
453
|
-
|
|
454
|
+
writeFileSecure(CONFIG_FILE, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
454
455
|
|
|
455
456
|
// Dynamically update the backup secrets.enc file to preserve the password hash
|
|
456
457
|
try {
|