total-recall-brain 3.21.3 โ 3.22.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/frontend/dist/assets/index-DJjuZ3t3.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/embeddings.ts +46 -0
- package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
- package/frontend/src/pages/MeshPage.tsx +5 -0
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +39 -0
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
- package/src/cli/secret.mjs +93 -12
- package/src/core/browser-session.mjs +185 -0
- package/src/core/embeddings.mjs +52 -10
- package/src/core/ollama-embeddings.mjs +336 -0
- package/src/core/provider-catalog.mjs +175 -6
- package/src/core/provider-rotation-recipes.mjs +299 -0
- package/src/core/rotation-capability.mjs +189 -0
- package/src/core/secrets-rotate.mjs +192 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +12 -0
- package/src/server/routes/embeddings.mjs +111 -0
- package/frontend/dist/assets/index-BwUTxKNm.js +0 -5742
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-DJjuZ3t3.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { get, post } from './_base'
|
|
2
|
+
|
|
3
|
+
/** One model on the Ollama host, annotated with why it can or cannot be used. */
|
|
4
|
+
export interface EmbeddingCandidate {
|
|
5
|
+
name: string
|
|
6
|
+
size: number | null
|
|
7
|
+
family: string | null
|
|
8
|
+
capabilities: string[]
|
|
9
|
+
/** Whether the model declares the embedding capability at all. */
|
|
10
|
+
embedding: boolean
|
|
11
|
+
/** Vector width the model produces, or null when it is not an embedding model. */
|
|
12
|
+
dims: number | null
|
|
13
|
+
/** True only when it embeds AND its width matches the vault index. */
|
|
14
|
+
compatible: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LocalEmbeddingProvider {
|
|
18
|
+
available: boolean
|
|
19
|
+
endpoint: string | null
|
|
20
|
+
selected: string | null
|
|
21
|
+
preferred: string | null
|
|
22
|
+
dims: number
|
|
23
|
+
candidates: EmbeddingCandidate[]
|
|
24
|
+
reason: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EmbeddingProviderStatus {
|
|
28
|
+
dims: number
|
|
29
|
+
local: LocalEmbeddingProvider
|
|
30
|
+
fallbacks: { provider: string; configured: boolean }[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchEmbeddingProvider(): Promise<EmbeddingProviderStatus> {
|
|
34
|
+
return get<EmbeddingProviderStatus>('/api/embeddings/provider')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pin a model, or pass null to return to automatic selection. */
|
|
38
|
+
export async function setEmbeddingModel(
|
|
39
|
+
model: string | null,
|
|
40
|
+
): Promise<{ ok: boolean; pinned: string | null; selected: string | null }> {
|
|
41
|
+
return post('/api/embeddings/model', { model })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function rediscoverEmbeddingProvider(): Promise<{ ok: boolean; local: LocalEmbeddingProvider }> {
|
|
45
|
+
return post('/api/embeddings/rediscover', {})
|
|
46
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
|
|
5
|
+
import { EmbeddingProviderPanel } from './EmbeddingProviderPanel'
|
|
6
|
+
import * as api from '../api/embeddings'
|
|
7
|
+
import type { EmbeddingProviderStatus } from '../api/embeddings'
|
|
8
|
+
|
|
9
|
+
vi.mock('../api/embeddings')
|
|
10
|
+
|
|
11
|
+
// Annotated against the real contract: without it TS narrows `endpoint` to
|
|
12
|
+
// string and `reason` to null from the literal, so the "nothing reachable"
|
|
13
|
+
// case below cannot be expressed.
|
|
14
|
+
const LOCAL_OK: EmbeddingProviderStatus = {
|
|
15
|
+
dims: 768,
|
|
16
|
+
local: {
|
|
17
|
+
available: true,
|
|
18
|
+
endpoint: 'http://100.64.0.2:11434',
|
|
19
|
+
selected: 'nomic-embed-text:latest',
|
|
20
|
+
preferred: null,
|
|
21
|
+
dims: 768,
|
|
22
|
+
candidates: [
|
|
23
|
+
{
|
|
24
|
+
name: 'nomic-embed-text:latest',
|
|
25
|
+
size: 274_000_000,
|
|
26
|
+
family: 'nomic-bert',
|
|
27
|
+
capabilities: ['embedding'],
|
|
28
|
+
embedding: true,
|
|
29
|
+
dims: 768,
|
|
30
|
+
compatible: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'gemma4:latest',
|
|
34
|
+
size: 9_608_000_000,
|
|
35
|
+
family: 'gemma4',
|
|
36
|
+
capabilities: ['completion'],
|
|
37
|
+
embedding: false,
|
|
38
|
+
dims: 2560,
|
|
39
|
+
compatible: false,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
reason: null,
|
|
43
|
+
},
|
|
44
|
+
fallbacks: [
|
|
45
|
+
{ provider: 'openrouter', configured: true },
|
|
46
|
+
{ provider: 'google', configured: false },
|
|
47
|
+
{ provider: 'openai', configured: false },
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(structuredClone(LOCAL_OK))
|
|
53
|
+
vi.mocked(api.setEmbeddingModel).mockResolvedValue({ ok: true, pinned: null, selected: null })
|
|
54
|
+
vi.mocked(api.rediscoverEmbeddingProvider).mockResolvedValue({ ok: true, local: LOCAL_OK.local })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('EmbeddingProviderPanel', () => {
|
|
58
|
+
it('shows the resolved endpoint and selected model', async () => {
|
|
59
|
+
render(<EmbeddingProviderPanel />)
|
|
60
|
+
expect(await screen.findByTestId('embed-endpoint')).toHaveTextContent('http://100.64.0.2:11434')
|
|
61
|
+
expect(screen.getByTestId('embed-selected')).toHaveTextContent('nomic-embed-text:latest')
|
|
62
|
+
expect(screen.getByTestId('embed-selected')).toHaveTextContent('auto')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('lists incompatible models with the reason instead of hiding them', async () => {
|
|
66
|
+
render(<EmbeddingProviderPanel />)
|
|
67
|
+
// An operator who pulled a chat model needs to see why it is unusable.
|
|
68
|
+
expect(await screen.findByText('gemma4:latest')).toBeInTheDocument()
|
|
69
|
+
expect(screen.getByText(/cannot embed/i)).toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('offers no "Use" button for a model that cannot embed', async () => {
|
|
73
|
+
render(<EmbeddingProviderPanel />)
|
|
74
|
+
await screen.findByText('gemma4:latest')
|
|
75
|
+
// Only compatible, non-selected models are pinnable; here that set is empty.
|
|
76
|
+
expect(screen.queryByRole('button', { name: /^use$/i })).not.toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('pins a model when the operator picks one', async () => {
|
|
80
|
+
const withOther = structuredClone(LOCAL_OK)
|
|
81
|
+
withOther.local.candidates.push({
|
|
82
|
+
name: 'other-embed:latest',
|
|
83
|
+
size: 100_000_000,
|
|
84
|
+
family: 'other',
|
|
85
|
+
capabilities: ['embedding'],
|
|
86
|
+
embedding: true,
|
|
87
|
+
dims: 768,
|
|
88
|
+
compatible: true,
|
|
89
|
+
})
|
|
90
|
+
vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(withOther)
|
|
91
|
+
|
|
92
|
+
render(<EmbeddingProviderPanel />)
|
|
93
|
+
const useBtn = await screen.findByRole('button', { name: /^use$/i })
|
|
94
|
+
await userEvent.click(useBtn)
|
|
95
|
+
|
|
96
|
+
await waitFor(() => expect(api.setEmbeddingModel).toHaveBeenCalledWith('other-embed:latest'))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('reports the hosted fallback state when nothing local answers', async () => {
|
|
100
|
+
const down = structuredClone(LOCAL_OK)
|
|
101
|
+
down.local = {
|
|
102
|
+
...down.local,
|
|
103
|
+
available: false,
|
|
104
|
+
endpoint: null,
|
|
105
|
+
selected: null,
|
|
106
|
+
candidates: [],
|
|
107
|
+
reason: 'No Ollama endpoint answered on this host or any online mesh peer.',
|
|
108
|
+
}
|
|
109
|
+
vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(down)
|
|
110
|
+
|
|
111
|
+
render(<EmbeddingProviderPanel />)
|
|
112
|
+
expect(await screen.findByTestId('embed-availability')).toHaveTextContent('hosted fallback')
|
|
113
|
+
expect(screen.getByRole('status')).toHaveTextContent(/No Ollama endpoint answered/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('surfaces a load failure rather than rendering an empty panel', async () => {
|
|
117
|
+
vi.mocked(api.fetchEmbeddingProvider).mockRejectedValue(new Error('Authentication required'))
|
|
118
|
+
render(<EmbeddingProviderPanel />)
|
|
119
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Authentication required')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('re-discovers on demand', async () => {
|
|
123
|
+
render(<EmbeddingProviderPanel />)
|
|
124
|
+
await userEvent.click(await screen.findByRole('button', { name: /re-discover/i }))
|
|
125
|
+
await waitFor(() => expect(api.rediscoverEmbeddingProvider).toHaveBeenCalled())
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fetchEmbeddingProvider,
|
|
5
|
+
setEmbeddingModel,
|
|
6
|
+
rediscoverEmbeddingProvider,
|
|
7
|
+
type EmbeddingProviderStatus,
|
|
8
|
+
type EmbeddingCandidate,
|
|
9
|
+
} from '../api/embeddings'
|
|
10
|
+
|
|
11
|
+
function formatSize(bytes: number | null): string {
|
|
12
|
+
if (!bytes) return 'โ'
|
|
13
|
+
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`
|
|
14
|
+
return `${Math.round(bytes / 1e6)} MB`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Explain why a model cannot be used, rather than hiding it. An operator who
|
|
19
|
+
* pulled the wrong model needs to see it listed with the reason attached.
|
|
20
|
+
*/
|
|
21
|
+
function incompatibilityReason(c: EmbeddingCandidate, dims: number): string {
|
|
22
|
+
if (!c.embedding) return 'chat model โ cannot embed'
|
|
23
|
+
if (c.dims !== dims) return `${c.dims ?? '?'} dims โ index needs ${dims}`
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function EmbeddingProviderPanel() {
|
|
28
|
+
const [status, setStatus] = useState<EmbeddingProviderStatus | null>(null)
|
|
29
|
+
const [error, setError] = useState<string | null>(null)
|
|
30
|
+
const [busy, setBusy] = useState(false)
|
|
31
|
+
|
|
32
|
+
const load = useCallback(async () => {
|
|
33
|
+
try {
|
|
34
|
+
setStatus(await fetchEmbeddingProvider())
|
|
35
|
+
setError(null)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
38
|
+
}
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
void load()
|
|
43
|
+
}, [load])
|
|
44
|
+
|
|
45
|
+
const onPin = useCallback(
|
|
46
|
+
async (model: string | null) => {
|
|
47
|
+
setBusy(true)
|
|
48
|
+
try {
|
|
49
|
+
await setEmbeddingModel(model)
|
|
50
|
+
await load()
|
|
51
|
+
setError(null)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
54
|
+
} finally {
|
|
55
|
+
setBusy(false)
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
[load],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const onRediscover = useCallback(async () => {
|
|
62
|
+
setBusy(true)
|
|
63
|
+
try {
|
|
64
|
+
await rediscoverEmbeddingProvider()
|
|
65
|
+
await load()
|
|
66
|
+
setError(null)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
69
|
+
} finally {
|
|
70
|
+
setBusy(false)
|
|
71
|
+
}
|
|
72
|
+
}, [load])
|
|
73
|
+
|
|
74
|
+
if (error && !status) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="panel">
|
|
77
|
+
<h3 style={{ marginTop: 0 }}>Embedding Provider</h3>
|
|
78
|
+
<p role="alert">{error}</p>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!status) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="panel">
|
|
86
|
+
<h3 style={{ marginTop: 0 }}>Embedding Provider</h3>
|
|
87
|
+
<p>Loadingโฆ</p>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { local, fallbacks, dims } = status
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="panel">
|
|
96
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
97
|
+
<h3 style={{ margin: 0 }}>Embedding Provider</h3>
|
|
98
|
+
<span
|
|
99
|
+
data-testid="embed-availability"
|
|
100
|
+
style={{ color: local.available ? 'var(--ok, #2ea043)' : 'var(--warn, #d29922)' }}
|
|
101
|
+
>
|
|
102
|
+
{local.available ? 'โ local' : 'โ hosted fallback'}
|
|
103
|
+
</span>
|
|
104
|
+
<button type="button" onClick={onRediscover} disabled={busy}>
|
|
105
|
+
Re-discover
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<dl style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px', margin: '12px 0' }}>
|
|
110
|
+
<dt>Endpoint</dt>
|
|
111
|
+
<dd data-testid="embed-endpoint" style={{ margin: 0 }}>
|
|
112
|
+
{local.endpoint ?? 'none reachable'}
|
|
113
|
+
</dd>
|
|
114
|
+
<dt>Model</dt>
|
|
115
|
+
<dd data-testid="embed-selected" style={{ margin: 0 }}>
|
|
116
|
+
{local.selected ?? 'โ'}
|
|
117
|
+
{local.preferred ? ' (pinned)' : local.selected ? ' (auto)' : ''}
|
|
118
|
+
</dd>
|
|
119
|
+
<dt>Vector width</dt>
|
|
120
|
+
<dd style={{ margin: 0 }}>{dims}</dd>
|
|
121
|
+
</dl>
|
|
122
|
+
|
|
123
|
+
{local.reason && <p role="status">{local.reason}</p>}
|
|
124
|
+
{error && <p role="alert">{error}</p>}
|
|
125
|
+
|
|
126
|
+
{local.candidates.length > 0 && (
|
|
127
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
128
|
+
<thead>
|
|
129
|
+
<tr>
|
|
130
|
+
<th style={{ textAlign: 'left' }}>Model</th>
|
|
131
|
+
<th style={{ textAlign: 'left' }}>Size</th>
|
|
132
|
+
<th style={{ textAlign: 'left' }}>Status</th>
|
|
133
|
+
<th />
|
|
134
|
+
</tr>
|
|
135
|
+
</thead>
|
|
136
|
+
<tbody>
|
|
137
|
+
{local.candidates.map((c) => {
|
|
138
|
+
const reason = incompatibilityReason(c, dims)
|
|
139
|
+
const isSelected = c.name === local.selected
|
|
140
|
+
return (
|
|
141
|
+
<tr key={c.name}>
|
|
142
|
+
<td>{c.name}</td>
|
|
143
|
+
<td>{formatSize(c.size)}</td>
|
|
144
|
+
<td style={{ opacity: c.compatible ? 1 : 0.6 }}>
|
|
145
|
+
{c.compatible ? `${c.dims} dims` : reason}
|
|
146
|
+
</td>
|
|
147
|
+
<td style={{ textAlign: 'right' }}>
|
|
148
|
+
{c.compatible && !isSelected && (
|
|
149
|
+
<button type="button" onClick={() => onPin(c.name)} disabled={busy}>
|
|
150
|
+
Use
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
{isSelected && local.preferred && (
|
|
154
|
+
<button type="button" onClick={() => onPin(null)} disabled={busy}>
|
|
155
|
+
Unpin
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
</td>
|
|
159
|
+
</tr>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
</tbody>
|
|
163
|
+
</table>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<div style={{ marginTop: 12, fontSize: '0.9em', opacity: 0.8 }}>
|
|
167
|
+
Fallback chain:{' '}
|
|
168
|
+
{fallbacks.map((f, i) => (
|
|
169
|
+
<span key={f.provider}>
|
|
170
|
+
{i > 0 && ' โ '}
|
|
171
|
+
<span style={{ textDecoration: f.configured ? 'none' : 'line-through' }}>{f.provider}</span>
|
|
172
|
+
</span>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
} from '../api/mesh';
|
|
25
25
|
import type { HeadscaleNode, PreAuthKey as HeadscalePreAuthKey, HeadscaleUser } from '../api/headscale';
|
|
26
26
|
import { MeshTopology } from '../components/MeshTopology';
|
|
27
|
+
import { EmbeddingProviderPanel } from '../components/EmbeddingProviderPanel';
|
|
27
28
|
import { LatencySparkline } from '../components/LatencySparkline';
|
|
28
29
|
import './MeshPage.css';
|
|
29
30
|
|
|
@@ -359,6 +360,10 @@ export function MeshPage() {
|
|
|
359
360
|
|
|
360
361
|
{activeTab === 'mesh' && (
|
|
361
362
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
363
|
+
{/* Which mesh node actually serves embeddings, and which model it uses. */}
|
|
364
|
+
<div className="card">
|
|
365
|
+
<EmbeddingProviderPanel />
|
|
366
|
+
</div>
|
|
362
367
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
|
363
368
|
<div className="card">
|
|
364
369
|
<h3 style={{ marginTop: 0, marginBottom: 12 }}>Topology</h3>
|
package/package.json
CHANGED
|
@@ -331,6 +331,45 @@ npx total-recall secret list # metadata only, no values
|
|
|
331
331
|
npx total-recall secret export-env --path <repo> # SSOT โ local .env.local
|
|
332
332
|
```
|
|
333
333
|
|
|
334
|
+
### Executed rotation โ `rotate-auto` / `rotate-browser` (added 3.22.0)
|
|
335
|
+
|
|
336
|
+
Rotation used to only ever **print instructions**. It now executes. Every key
|
|
337
|
+
resolves to one of four rotation classes, so nothing is silently unrotatable:
|
|
338
|
+
|
|
339
|
+
| Class | How it rotates | Human needed? |
|
|
340
|
+
|---|---|---|
|
|
341
|
+
| `self_generated` | TR mints the value itself (`JWT_SECRET`, `DB_PASSWORD`, `*_WEBHOOK_SECRET`) | **No** |
|
|
342
|
+
| `provider_browser` | TR drives the provider console in its own browser profile | Login/2FA once |
|
|
343
|
+
| `provider_api` | Provider key-management API | No |
|
|
344
|
+
| `manual` | Human-only artifact (SSH keys, recovery codes, OAuth app secrets) | Yes, with reason |
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
npx total-recall secret rotation-status # coverage for every key
|
|
348
|
+
npx total-recall secret rotation-status --json
|
|
349
|
+
npx total-recall secret rotate-auto <key> # pick the right method automatically
|
|
350
|
+
npx total-recall secret rotate-browser <key> # force the console path
|
|
351
|
+
npx total-recall secret rotate-browser <key> --print-only # legacy: just print steps
|
|
352
|
+
npx total-recall secret browser-logout # wipe TR's browser profile
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Browser profile.** Console rotation uses a persistent Chromium profile at
|
|
356
|
+
`<brain>/browser-profile` (created `0700`). You log in once per provider; the
|
|
357
|
+
daemon can then rotate unattended. That directory holds live Stripe/GitHub
|
|
358
|
+
sessions โ treat it as **equally sensitive to `secrets.enc`**: never sync it,
|
|
359
|
+
never back it up off-machine, and `browser-logout` when you're done.
|
|
360
|
+
|
|
361
|
+
**Safety invariants** (enforced by `rotation-capability.spec.mjs`):
|
|
362
|
+
|
|
363
|
+
- The new value is read from the browser's own clipboard **inside TR's process** โ
|
|
364
|
+
it never transits an argv string, an env var, or a chat transcript.
|
|
365
|
+
- Only recipes marked `verified: true` auto-click. Unverified providers run
|
|
366
|
+
**supervised** โ TR opens the console, tells you what to click, and detects the
|
|
367
|
+
result. It never blind-clicks guessed selectors on a billing dashboard.
|
|
368
|
+
- A captured value is shape-checked against the provider pattern and, where an
|
|
369
|
+
API probe exists, **verified to authenticate before it replaces the old one**.
|
|
370
|
+
A failed check stores nothing.
|
|
371
|
+
- The old credential is never revoked until the new one is stored and exported.
|
|
372
|
+
|
|
334
373
|
### Remote (production) deploy โ `secret remote` (added 3.21.1)
|
|
335
374
|
|
|
336
375
|
`export-env` only ever wrote a **local** file โ there was no path from the
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"id":"0efa65c2-0231-4267-a304-e0285e50011e","topic":"Total Recall repository improvement research: SSSS validated write pipeline, filesystem-native memory engines, local-first semantic indexing, Express 5 API hardening, 2026 agent memory architecture patterns","status":"pending","priority":"high","notes":"Queued from repo-wide analysis request on 2026-06-01. Focus on current best practices and meta-patterns for filesystem-native memory OS, validated operation envelopes, append-only logs, cache invalidation, research queue architecture, and secure local-first AI tools.","node_slug":null,"research_phase":"acquisition","created_at":"2026-06-01T06:07:40.102Z","updated_at":"2026-06-01T06:07:40.126Z","completed_at":null,"summary":{"id":"0efa65c2-0231-4267-a304-e0285e50011e","topic":"Total Recall repository improvement research: SSSS validated write pipeline, filesystem-native memory engines, local-first semantic indexing, Express 5 API hardening, 2026 agent memory architecture patterns","status":"pending","phase":"acquisition","last_updated":"2026-06-01T06:07:40.126Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
package/src/cli/secret.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
updateSecretMeta,
|
|
30
30
|
listRotationDue,
|
|
31
31
|
getSharedValueHealth,
|
|
32
|
+
loadSecrets,
|
|
32
33
|
} from '../core/secrets-store.mjs';
|
|
33
34
|
import {
|
|
34
35
|
scanEnvSources,
|
|
@@ -140,7 +141,13 @@ function printHelp() {
|
|
|
140
141
|
--headscale-url <https-url>
|
|
141
142
|
rotation-due List keys overdue for rotation
|
|
142
143
|
--enqueue Create daemon tasks + browser-use prompts for each due key
|
|
143
|
-
rotate-browser <key>
|
|
144
|
+
rotate-browser <key> Drive the provider console in TR's browser and rotate
|
|
145
|
+
--print-only Only print instructions (legacy behaviour)
|
|
146
|
+
--headless Allow headless for verified recipes
|
|
147
|
+
rotate-auto <key> Rotate by whatever method the key's class supports
|
|
148
|
+
rotation-status Rotation coverage for every key in the vault
|
|
149
|
+
--json Machine-readable output
|
|
150
|
+
browser-logout Delete TR's persistent browser profile (all sessions)
|
|
144
151
|
usage Cost/events (add --key-ref for per-key)
|
|
145
152
|
|
|
146
153
|
rotate options:
|
|
@@ -239,9 +246,18 @@ function parseArgs(args) {
|
|
|
239
246
|
}
|
|
240
247
|
}
|
|
241
248
|
if (
|
|
242
|
-
[
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
[
|
|
250
|
+
'set',
|
|
251
|
+
'get',
|
|
252
|
+
'rotate',
|
|
253
|
+
'delete',
|
|
254
|
+
'meta',
|
|
255
|
+
'rotate-browser',
|
|
256
|
+
'rotate-auto',
|
|
257
|
+
'rm',
|
|
258
|
+
'generate',
|
|
259
|
+
'account-sync',
|
|
260
|
+
].includes(out.command) &&
|
|
245
261
|
args[i] &&
|
|
246
262
|
!args[i].startsWith('--')
|
|
247
263
|
) {
|
|
@@ -376,6 +392,15 @@ function parseArgs(args) {
|
|
|
376
392
|
case '--enqueue':
|
|
377
393
|
out.enqueue = true;
|
|
378
394
|
break;
|
|
395
|
+
case '--print-only':
|
|
396
|
+
out.printOnly = true;
|
|
397
|
+
break;
|
|
398
|
+
case '--headless':
|
|
399
|
+
out.headless = true;
|
|
400
|
+
break;
|
|
401
|
+
case '--json':
|
|
402
|
+
out.json = true;
|
|
403
|
+
break;
|
|
379
404
|
case '--bytes':
|
|
380
405
|
out.bytes = parseInt(args[++i], 10) || 32;
|
|
381
406
|
break;
|
|
@@ -945,17 +970,73 @@ export default async function secretCli(argv) {
|
|
|
945
970
|
return;
|
|
946
971
|
}
|
|
947
972
|
|
|
948
|
-
if (opts.command === 'rotate-browser') {
|
|
973
|
+
if (opts.command === 'rotate-browser' || opts.command === 'rotate-auto') {
|
|
949
974
|
if (!opts.key) {
|
|
950
|
-
console.error(
|
|
975
|
+
console.error(`Usage: total-recall secret ${opts.command} <key>`);
|
|
976
|
+
process.exit(1);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (opts.printOnly) {
|
|
980
|
+
const assist = await getBrowserRotateAssist(brainDir, opts.key);
|
|
981
|
+
console.log(`\n ๐ Supervised browser rotation โ ${assist.key}\n`);
|
|
982
|
+
if (assist.console_url) console.log(` Console: ${assist.console_url}`);
|
|
983
|
+
if (assist.docs_url) console.log(` Docs: ${assist.docs_url}`);
|
|
984
|
+
console.log(` Overdue: ${assist.overdue ? 'YES' : 'no'}`);
|
|
985
|
+
console.log('\n' + assist.prompt + '\n');
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const { rotateViaBrowser, rotateAuto } = await import('../core/secrets-rotate.mjs');
|
|
990
|
+
const run = opts.command === 'rotate-auto' ? rotateAuto : rotateViaBrowser;
|
|
991
|
+
console.log(`\n ๐ Rotating ${opts.key}โฆ\n`);
|
|
992
|
+
const r = await run(brainDir, opts.key, {
|
|
993
|
+
headless: !!opts.headless,
|
|
994
|
+
exportEnv: opts.exportEnv !== false,
|
|
995
|
+
onStatus: (m) => console.log(` ยท ${m}`),
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
if (!r.ok) {
|
|
999
|
+
console.error(`\n โ ${r.error}`);
|
|
1000
|
+
if (r.console_url) console.error(` Console: ${r.console_url}`);
|
|
951
1001
|
process.exit(1);
|
|
952
1002
|
}
|
|
953
|
-
|
|
954
|
-
console.log(
|
|
955
|
-
if (
|
|
956
|
-
if (
|
|
957
|
-
console.log(
|
|
958
|
-
|
|
1003
|
+
console.log(`\n โ
Rotated ${r.key}` + (r.method ? ` via ${r.method}` : ''));
|
|
1004
|
+
if (r.verified === true) console.log(' verified against provider API');
|
|
1005
|
+
if (r.supervised) console.log(' (supervised โ recipe not yet auto-verified)');
|
|
1006
|
+
if (r.exports?.length) console.log(` exported to ${r.exports.length} project(s)`);
|
|
1007
|
+
if (r.revoke_hint) console.log(`\n โ ๏ธ ${r.revoke_hint}`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (opts.command === 'rotation-status') {
|
|
1012
|
+
const { planAll, summarizePlans } = await import('../core/rotation-capability.mjs');
|
|
1013
|
+
const all = await loadSecrets(brainDir);
|
|
1014
|
+
const keys = Object.keys(all || {}).filter((k) => k !== 'meta');
|
|
1015
|
+
const plans = planAll(keys, all.meta || {});
|
|
1016
|
+
const sum = summarizePlans(plans);
|
|
1017
|
+
|
|
1018
|
+
if (opts.json) {
|
|
1019
|
+
console.log(JSON.stringify({ summary: sum, plans }, null, 2));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
console.log(`\n ๐ Rotation coverage โ ${sum.automatable}/${sum.total} automatable\n`);
|
|
1024
|
+
for (const [cls, n] of Object.entries(sum.byClass)) {
|
|
1025
|
+
console.log(` ${cls.padEnd(18)} ${n}`);
|
|
1026
|
+
}
|
|
1027
|
+
const manual = plans.filter((p) => !p.automatable && p.class !== 'non_secret');
|
|
1028
|
+
if (manual.length) {
|
|
1029
|
+
console.log(`\n Needs a human (${manual.length}):`);
|
|
1030
|
+
for (const p of manual) console.log(` ${p.key.padEnd(34)} ${p.reason}`);
|
|
1031
|
+
}
|
|
1032
|
+
console.log('');
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (opts.command === 'browser-logout') {
|
|
1037
|
+
const { clearProfile } = await import('../core/browser-session.mjs');
|
|
1038
|
+
const r = clearProfile(brainDir);
|
|
1039
|
+
console.log(r.removed ? ` โ
Cleared browser profile: ${r.path}` : ` (no profile at ${r.path})`);
|
|
959
1040
|
return;
|
|
960
1041
|
}
|
|
961
1042
|
|