total-recall-brain 3.30.0 → 3.30.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/package.json
CHANGED
|
@@ -181,6 +181,10 @@ npx total-recall compile
|
|
|
181
181
|
```
|
|
182
182
|
Synchronously rebuild the entire memory index (derived embeddings) and regenerate prompt instruction shims (such as `INSTRUCTIONS.md`, `GEMINI.md`, `AGENTS.md`, etc.).
|
|
183
183
|
|
|
184
|
+
* **Rules are global + local.** Every project's shims carry the global brain's invariants, preferences and corrections plus the project's own; a project node with the same slug overrides the global one.
|
|
185
|
+
* `compile --global` (what `remember --global` runs) and `compile --all` also recompile every registered project, so a global rule reaches every repo at once.
|
|
186
|
+
* A client gets a shim only once it is connected (`total-recall connect claude-code|codex|antigravity|…` in that repo). No `CLAUDE.md` in a repo means Claude Code was never connected there.
|
|
187
|
+
|
|
184
188
|
### 5. Ingest OKF Bundle
|
|
185
189
|
```bash
|
|
186
190
|
npx total-recall ingest okf <path> [options]
|
|
@@ -394,11 +398,23 @@ transcript. `set`, `rotate` and `rekey` all accept it.
|
|
|
394
398
|
### Master password and `rekey`
|
|
395
399
|
|
|
396
400
|
The store is AES-256-GCM; the key is scrypt-derived (N=2^16, ~64 MB) from
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
`
|
|
400
|
-
|
|
401
|
-
|
|
401
|
+
the master password, resolved in this order:
|
|
402
|
+
|
|
403
|
+
1. `TR_SECRETS_PASSWORD` / `TR_MASTER_PASSWORD` in the environment (a
|
|
404
|
+
LaunchAgent plist injects it for launchd jobs);
|
|
405
|
+
2. macOS: the **Keychain** entry `total-recall-secrets`
|
|
406
|
+
(`security add-generic-password -s total-recall-secrets`). An ssh or mesh
|
|
407
|
+
session cannot read a locked login Keychain;
|
|
408
|
+
3. Linux and headless hosts: the env file `~/.agent/tr.env` (or `TR_ENV_FILE`)
|
|
409
|
+
containing `export TR_SECRETS_PASSWORD='…'`. It is used only when it is a
|
|
410
|
+
regular file, mode 0600, owned by you, in a directory (keep `~/.agent` 0700)
|
|
411
|
+
nobody else can write. Do not also export it from `.bashrc` — every process
|
|
412
|
+
started from the shell would inherit it.
|
|
413
|
+
|
|
414
|
+
`rekey` rotates every carrier (LaunchAgent plist, `--env-file`, Keychain) in one
|
|
415
|
+
transaction, verifying the retired password no longer decrypts before it
|
|
416
|
+
commits. No carrier on a machine means every command fails with "Secrets file
|
|
417
|
+
is not valid JSON" and `mesh` falls back to local discovery.
|
|
402
418
|
|
|
403
419
|
> **Processes hold the old password until restarted.** After a rekey, anything
|
|
404
420
|
> still running with the retired value fails with `Failed to decrypt secrets
|
|
@@ -584,7 +600,7 @@ npm test # vitest run — whole suite
|
|
|
584
600
|
npx vitest run <path> # one spec file
|
|
585
601
|
```
|
|
586
602
|
|
|
587
|
-
Current baseline: **
|
|
603
|
+
Current baseline (3.30.1): **349 spec files / 2024 tests, ~160 s** on the Mac Mini.
|
|
588
604
|
|
|
589
605
|
> **Never run the full suite, a full typecheck, or a production build on the
|
|
590
606
|
> laptop.** These go to the Mac Mini or the droplet — local runs cause slowdowns
|
package/src/cli/connect.mjs
CHANGED
|
@@ -369,10 +369,19 @@ function apiDetails(opts) {
|
|
|
369
369
|
models_url: `${base}/v1/models`,
|
|
370
370
|
ssss_manifest_url: `${base}/api/ssss`,
|
|
371
371
|
discovery_url: `${base}/.well-known/total-recall.json`,
|
|
372
|
-
|
|
372
|
+
// Never the token itself: this goes to stdout and --json, where it lands in
|
|
373
|
+
// terminal scrollback, CI logs and agent transcripts. Clients read the
|
|
374
|
+
// token from brain.json (0600).
|
|
375
|
+
authorization_header: opts.token ? `Authorization: Bearer ${maskToken(opts.token)}` : 'Authorization: Bearer <PAT>'
|
|
373
376
|
};
|
|
374
377
|
}
|
|
375
378
|
|
|
379
|
+
export function maskToken(token) {
|
|
380
|
+
const value = String(token || '');
|
|
381
|
+
if (value.length <= 8) return '<PAT>';
|
|
382
|
+
return `${value.slice(0, 6)}… (full token in config/brain.json)`;
|
|
383
|
+
}
|
|
384
|
+
|
|
376
385
|
function detectObsidianVault() {
|
|
377
386
|
// macOS: Obsidian stores vault paths in its app support config
|
|
378
387
|
const obsidianConfig = path.join(
|
package/src/cli/update.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* npx total-recall update --apply # install latest where needed
|
|
7
7
|
* npx total-recall update --apply --force
|
|
8
8
|
* npx total-recall update --repo /path/to/app
|
|
9
|
+
* npx total-recall update --install-autopull [--port N] [--no-build]
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
PACKAGE_NAME,
|
|
16
17
|
} from '../core/package-auto-update.mjs';
|
|
17
18
|
import { brainDir } from '../core/config.mjs';
|
|
19
|
+
import { installAutoPull } from '../core/autopull-install.mjs';
|
|
18
20
|
|
|
19
21
|
/** @param {string[]} args */
|
|
20
22
|
export function parseArgs(args) {
|
|
@@ -24,6 +26,9 @@ export function parseArgs(args) {
|
|
|
24
26
|
dryRun: false,
|
|
25
27
|
help: false,
|
|
26
28
|
repos: [],
|
|
29
|
+
installAutopull: false,
|
|
30
|
+
port: null,
|
|
31
|
+
noBuild: false,
|
|
27
32
|
};
|
|
28
33
|
for (let i = 0; i < args.length; i++) {
|
|
29
34
|
const a = args[i];
|
|
@@ -32,6 +37,9 @@ export function parseArgs(args) {
|
|
|
32
37
|
else if (a === '--force') opts.force = true;
|
|
33
38
|
else if (a === '--dry-run') opts.dryRun = true;
|
|
34
39
|
else if (a === '--repo' && args[i + 1]) opts.repos.push(args[++i]);
|
|
40
|
+
else if (a === '--install-autopull') opts.installAutopull = true;
|
|
41
|
+
else if (a === '--port' && args[i + 1]) opts.port = Number(args[++i]);
|
|
42
|
+
else if (a === '--no-build') opts.noBuild = true;
|
|
35
43
|
}
|
|
36
44
|
return opts;
|
|
37
45
|
}
|
|
@@ -48,6 +56,15 @@ function printHelp() {
|
|
|
48
56
|
total-recall update --apply --force Ignore throttle / disabled flag
|
|
49
57
|
total-recall update --repo <path> Include extra root (repeatable)
|
|
50
58
|
|
|
59
|
+
This brain's own checkout (git):
|
|
60
|
+
total-recall update --install-autopull [--port N] [--no-build] [--dry-run]
|
|
61
|
+
Every 5 minutes fast-forward this checkout to origin/main, install and
|
|
62
|
+
rebuild what changed, restart the brain and verify /health reports the
|
|
63
|
+
new version (scripts/auto-pull.sh). macOS: LaunchAgent
|
|
64
|
+
com.totalrecall.autopull; Linux: crontab. A dev checkout (other branch,
|
|
65
|
+
uncommitted or unpushed work) is never touched. --no-build: never build
|
|
66
|
+
the dashboard on this host (laptops).
|
|
67
|
+
|
|
51
68
|
Env:
|
|
52
69
|
TR_AUTO_UPDATE_PACKAGE=0 Disable daemon cron auto-update (default: on)
|
|
53
70
|
TR_SYNC_REPOS=/a:/b Extra project roots
|
|
@@ -63,6 +80,18 @@ export default async function update(args = []) {
|
|
|
63
80
|
return;
|
|
64
81
|
}
|
|
65
82
|
|
|
83
|
+
if (opts.installAutopull) {
|
|
84
|
+
const result = installAutoPull({ port: opts.port || undefined, noBuild: opts.noBuild, dryRun: opts.dryRun });
|
|
85
|
+
if (opts.dryRun) {
|
|
86
|
+
console.log(JSON.stringify(result, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const where = result.plistPath ? `LaunchAgent ${result.plistPath}` : `crontab: ${result.cronLine}`;
|
|
90
|
+
console.log(`\n ✅ Auto-pull installed (${where}), checking port ${result.port} every 5 minutes.`);
|
|
91
|
+
console.log(' Log: ~/.agent/logs/auto-pull.log\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
66
95
|
const latest = fetchLatestNpmVersion();
|
|
67
96
|
console.log(`\n Package: ${PACKAGE_NAME}`);
|
|
68
97
|
console.log(` Latest on npm: ${latest || '(unavailable)'}`);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install the auto-pull timer on a host that serves a brain from a git checkout.
|
|
3
|
+
*
|
|
4
|
+
* The daemon's package auto-update only reaches npm installs in registered
|
|
5
|
+
* projects; it deliberately skips source checkouts. A brain running from a
|
|
6
|
+
* checkout therefore never updated unless someone pulled and restarted it by
|
|
7
|
+
* hand, and the one host with a timer (a droplet cron) pointed at a script that
|
|
8
|
+
* could not restart anything. This installs scripts/auto-pull.sh — the repo's
|
|
9
|
+
* own copy, so the updater updates itself — on a 5-minute timer:
|
|
10
|
+
* - macOS: a LaunchAgent (com.totalrecall.autopull) with StartInterval;
|
|
11
|
+
* - Linux: a crontab line, replacing any earlier auto-pull line.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here names a machine or a repository: paths come from where this
|
|
14
|
+
* package is running, ports from flags, the environment or the brain's own
|
|
15
|
+
* LaunchAgent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
export const AUTOPULL_LABEL = 'com.totalrecall.autopull';
|
|
25
|
+
export const AUTOPULL_INTERVAL_SECONDS = 300;
|
|
26
|
+
|
|
27
|
+
/** The checkout this CLI runs from (…/src/core → repo root). */
|
|
28
|
+
export function packageRoot() {
|
|
29
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function xmlEscape(value) {
|
|
33
|
+
return String(value)
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** LaunchAgent that runs the checkout's auto-pull.sh every `interval` seconds. */
|
|
41
|
+
export function buildLaunchAgentPlist({ script, env = {}, logFile, interval = AUTOPULL_INTERVAL_SECONDS, label = AUTOPULL_LABEL }) {
|
|
42
|
+
const envEntries = Object.entries(env)
|
|
43
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
44
|
+
.map(([k, v]) => `\t\t<key>${xmlEscape(k)}</key>\n\t\t<string>${xmlEscape(v)}</string>`)
|
|
45
|
+
.join('\n');
|
|
46
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
48
|
+
<plist version="1.0">
|
|
49
|
+
<dict>
|
|
50
|
+
\t<key>Label</key>
|
|
51
|
+
\t<string>${xmlEscape(label)}</string>
|
|
52
|
+
\t<key>ProgramArguments</key>
|
|
53
|
+
\t<array>
|
|
54
|
+
\t\t<string>/bin/bash</string>
|
|
55
|
+
\t\t<string>${xmlEscape(script)}</string>
|
|
56
|
+
\t</array>
|
|
57
|
+
\t<key>EnvironmentVariables</key>
|
|
58
|
+
\t<dict>
|
|
59
|
+
${envEntries}
|
|
60
|
+
\t</dict>
|
|
61
|
+
\t<key>StartInterval</key>
|
|
62
|
+
\t<integer>${Number(interval)}</integer>
|
|
63
|
+
\t<key>RunAtLoad</key>
|
|
64
|
+
\t<true/>
|
|
65
|
+
\t<key>StandardOutPath</key>
|
|
66
|
+
\t<string>${xmlEscape(logFile)}</string>
|
|
67
|
+
\t<key>StandardErrorPath</key>
|
|
68
|
+
\t<string>${xmlEscape(logFile)}</string>
|
|
69
|
+
</dict>
|
|
70
|
+
</plist>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shellQuote(value) {
|
|
75
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The crontab line that runs auto-pull.sh every 5 minutes. */
|
|
79
|
+
export function buildCronLine({ script, env = {} }) {
|
|
80
|
+
const assignments = Object.entries(env)
|
|
81
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
82
|
+
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
83
|
+
.join(' ');
|
|
84
|
+
return `*/5 * * * * ${assignments ? `${assignments} ` : ''}/bin/bash ${shellQuote(script)} >/dev/null 2>&1`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Replace every earlier auto-pull line with `line`; keep everything else. */
|
|
88
|
+
export function mergeCrontab(existing, line) {
|
|
89
|
+
const kept = String(existing || '')
|
|
90
|
+
.split('\n')
|
|
91
|
+
.filter((l) => l.trim() && !/auto-pull\.sh/.test(l));
|
|
92
|
+
return `${[...kept, line].join('\n')}\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The port the brain from `repoDir` serves: the PORT of the LaunchAgent that
|
|
97
|
+
* runs it (macOS), else null.
|
|
98
|
+
*/
|
|
99
|
+
export function detectLaunchAgentPort(repoDir, { home = os.homedir(), readPlist = readPlistValue } = {}) {
|
|
100
|
+
const dir = path.join(home, 'Library', 'LaunchAgents');
|
|
101
|
+
if (!fs.existsSync(dir)) return null;
|
|
102
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
103
|
+
if (!entry.endsWith('.plist') || entry === `${AUTOPULL_LABEL}.plist`) continue;
|
|
104
|
+
const file = path.join(dir, entry);
|
|
105
|
+
const cwd = readPlist(file, 'WorkingDirectory');
|
|
106
|
+
const args = readPlist(file, 'ProgramArguments') || '';
|
|
107
|
+
if (cwd !== repoDir && !args.includes(`${repoDir}/`)) continue;
|
|
108
|
+
const port = readPlist(file, 'EnvironmentVariables:PORT');
|
|
109
|
+
if (port && /^\d+$/.test(port.trim())) return Number(port.trim());
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readPlistValue(file, key) {
|
|
115
|
+
try {
|
|
116
|
+
return execFileSync('/usr/libexec/PlistBuddy', ['-c', `Print :${key}`, file], {
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
119
|
+
}).trim();
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Install (or refresh) the timer. Returns what it did; `dryRun` returns the
|
|
127
|
+
* plan without writing.
|
|
128
|
+
*/
|
|
129
|
+
export function installAutoPull({
|
|
130
|
+
repoDir = packageRoot(),
|
|
131
|
+
port,
|
|
132
|
+
noBuild = false,
|
|
133
|
+
platform = process.platform,
|
|
134
|
+
home = os.homedir(),
|
|
135
|
+
dryRun = false,
|
|
136
|
+
} = {}) {
|
|
137
|
+
const script = path.join(repoDir, 'scripts', 'auto-pull.sh');
|
|
138
|
+
if (!fs.existsSync(path.join(repoDir, '.git'))) {
|
|
139
|
+
throw new Error(`${repoDir} is not a git checkout; npm installs update through the daemon (total-recall update --apply).`);
|
|
140
|
+
}
|
|
141
|
+
if (!fs.existsSync(script)) throw new Error(`Missing ${script}`);
|
|
142
|
+
|
|
143
|
+
const resolvedPort =
|
|
144
|
+
port || Number(process.env.TR_PORT) || (platform === 'darwin' ? detectLaunchAgentPort(repoDir, { home }) : null) || 3000;
|
|
145
|
+
const env = {
|
|
146
|
+
TR_REPO_DIR: repoDir,
|
|
147
|
+
TR_PORT: String(resolvedPort),
|
|
148
|
+
TR_AUTOPULL_NO_BUILD: noBuild ? '1' : undefined,
|
|
149
|
+
};
|
|
150
|
+
const logDir = path.join(home, '.agent', 'logs');
|
|
151
|
+
|
|
152
|
+
if (platform === 'darwin') {
|
|
153
|
+
const plistPath = path.join(home, 'Library', 'LaunchAgents', `${AUTOPULL_LABEL}.plist`);
|
|
154
|
+
const plist = buildLaunchAgentPlist({
|
|
155
|
+
script,
|
|
156
|
+
env: { ...env, HOME: home, PATH: process.env.PATH },
|
|
157
|
+
logFile: path.join(logDir, 'auto-pull.launchd.log'),
|
|
158
|
+
});
|
|
159
|
+
if (dryRun) return { platform, plistPath, plist, port: resolvedPort };
|
|
160
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
161
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
162
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
163
|
+
const domain = `gui/${process.getuid()}`;
|
|
164
|
+
spawnSync('launchctl', ['bootout', `${domain}/${AUTOPULL_LABEL}`], { stdio: 'ignore' });
|
|
165
|
+
const boot = spawnSync('launchctl', ['bootstrap', domain, plistPath], { encoding: 'utf8' });
|
|
166
|
+
if (boot.status !== 0) throw new Error(`launchctl bootstrap failed: ${(boot.stderr || '').trim()}`);
|
|
167
|
+
return { platform, plistPath, port: resolvedPort, installed: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const line = buildCronLine({ script, env });
|
|
171
|
+
const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
172
|
+
const merged = mergeCrontab(current.status === 0 ? current.stdout : '', line);
|
|
173
|
+
if (dryRun) return { platform, cronLine: line, crontab: merged, port: resolvedPort };
|
|
174
|
+
const write = spawnSync('crontab', ['-'], { input: merged, encoding: 'utf8' });
|
|
175
|
+
if (write.status !== 0) throw new Error(`crontab write failed: ${(write.stderr || '').trim()}`);
|
|
176
|
+
return { platform, cronLine: line, port: resolvedPort, installed: true };
|
|
177
|
+
}
|
|
@@ -43,7 +43,8 @@ export function resolveUsagePath(brainDir) {
|
|
|
43
43
|
/**
|
|
44
44
|
* The master password: the environment first, then — on macOS — the Keychain
|
|
45
45
|
* entry `secret rekey` already maintains (service `total-recall-secrets`,
|
|
46
|
-
* overridable with TR_SECRETS_KEYCHAIN_SERVICE)
|
|
46
|
+
* overridable with TR_SECRETS_KEYCHAIN_SERVICE), then the host env file
|
|
47
|
+
* (see readTrEnvFilePassword).
|
|
47
48
|
*
|
|
48
49
|
* Only an interactive shell profile used to read the Keychain, so a
|
|
49
50
|
* non-interactive process on the same Mac — a mesh `exec`, a release script —
|
|
@@ -52,13 +53,56 @@ export function resolveUsagePath(brainDir) {
|
|
|
52
53
|
* disables the fallback.
|
|
53
54
|
*/
|
|
54
55
|
let keychainPassword;
|
|
55
|
-
export function secretsPassword({
|
|
56
|
+
export function secretsPassword({
|
|
57
|
+
env = process.env,
|
|
58
|
+
readKeychain = readTrKeychainPassword,
|
|
59
|
+
readEnvFile = readTrEnvFilePassword,
|
|
60
|
+
} = {}) {
|
|
56
61
|
const fromEnv = env.TR_SECRETS_PASSWORD || env.TR_MASTER_PASSWORD;
|
|
57
62
|
if (fromEnv) return fromEnv;
|
|
58
|
-
if (env.TR_SECRETS_NO_KEYCHAIN
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
if (env.TR_SECRETS_NO_KEYCHAIN !== '1') {
|
|
64
|
+
let fromKeychain;
|
|
65
|
+
if (readKeychain !== readTrKeychainPassword) fromKeychain = readKeychain(env) || null;
|
|
66
|
+
else {
|
|
67
|
+
if (keychainPassword === undefined) keychainPassword = readKeychain(env) || null;
|
|
68
|
+
fromKeychain = keychainPassword;
|
|
69
|
+
}
|
|
70
|
+
if (fromKeychain) return fromKeychain;
|
|
71
|
+
}
|
|
72
|
+
return readEnvFile(env) || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The host's env-file carrier: `TR_ENV_FILE`, else `~/.agent/tr.env` — the file
|
|
77
|
+
* `auto-pull.sh` sources and `secret rekey --env-file` rotates. Only a shell
|
|
78
|
+
* that sources it used to see the password, so a non-interactive process on a
|
|
79
|
+
* Linux host (an agent's tool shell, cron, a systemd unit) could not open the
|
|
80
|
+
* store its own user owns. The file is honoured only when it is a regular file
|
|
81
|
+
* (not a symlink) that this user owns and no one else can read or write, in a
|
|
82
|
+
* directory this user owns that no one else can write to.
|
|
83
|
+
*/
|
|
84
|
+
export function readTrEnvFilePassword(env = process.env) {
|
|
85
|
+
const file = env.TR_ENV_FILE || path.join(env.HOME || os.homedir(), '.agent', 'tr.env');
|
|
86
|
+
try {
|
|
87
|
+
// lstat: a symlink could point the carrier at a file someone else controls.
|
|
88
|
+
const stat = fs.lstatSync(file);
|
|
89
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0) return null;
|
|
90
|
+
// A directory others can write to lets them replace the file.
|
|
91
|
+
const dir = fs.statSync(path.dirname(file));
|
|
92
|
+
if ((dir.mode & 0o022) !== 0) return null;
|
|
93
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
94
|
+
if (uid !== null && (stat.uid !== uid || dir.uid !== uid)) return null;
|
|
95
|
+
const line = fs
|
|
96
|
+
.readFileSync(file, 'utf8')
|
|
97
|
+
.split(/\r?\n/)
|
|
98
|
+
.find((entry) => /^\s*(?:export\s+)?TR_SECRETS_PASSWORD\s*=/.test(entry));
|
|
99
|
+
if (!line) return null;
|
|
100
|
+
let value = line.slice(line.indexOf('=') + 1).trim();
|
|
101
|
+
if (value.length >= 2 && /^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
|
|
102
|
+
return value || null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
62
106
|
}
|
|
63
107
|
|
|
64
108
|
function readTrKeychainPassword(env) {
|
package/src/server/index.mjs
CHANGED
|
@@ -280,7 +280,10 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
280
280
|
const unembeddedBrains = (embeddingCoverage || []).filter(c => c.nodes > 0 && c.embedded === 0);
|
|
281
281
|
|
|
282
282
|
// Determine overall status
|
|
283
|
-
|
|
283
|
+
// No agent CLIs on PATH is normal for a headless server brain (a droplet has
|
|
284
|
+
// none), so it is reported in cli_agents but does not make the brain
|
|
285
|
+
// "degraded" — that status is for things that break memory itself.
|
|
286
|
+
const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead'
|
|
284
287
|
|| unembeddedBrains.length > 0;
|
|
285
288
|
|
|
286
289
|
// Check Caddy and Cloudflare status
|