Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion deploy/fly/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ fly logs --app "$APP" | grep -A1 AGENTMEMORY_SECRET=

You will see exactly one line of the form `AGENTMEMORY_SECRET=<64 hex chars>`.
Copy it into your client environment (`~/.bashrc`, Claude Desktop config,
etc.). The secret is never printed again on subsequent boots.
the viewer unlock prompt, etc.). The secret is never printed again on
subsequent boots.

If the first-boot log line is no longer available, read the persisted
secret from the mounted volume:

```bash
fly ssh console --app "$APP" -C "sh -lc 'cat /data/.hmac'"
```

## Verify the deployment

Expand All @@ -75,6 +83,32 @@ fly proxy 3113:3113 --app "$APP"
viewer's bearer token still has to ride a loopback connection on your
laptop — the v0.9.12 plaintext-bearer guard stays satisfied.

The entrypoint sets `AGENTMEMORY_VIEWER_HOST=::` **only when it detects
Fly's runtime variables** (`FLY_APP_NAME` / `FLY_ALLOC_ID`). That makes
the viewer listen on the machine's `fly-local-6pn` WireGuard interface
as well as loopback so `fly proxy` can reach it. The same branch
preseeds `VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix wording typo in allowlist description.

Line 90 uses “preseeds”; replace with “pre-seeds” (or “prepopulates”) for clearer docs text.

✏️ Proposed doc tweak
-The same branch preseeds `VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`,
+The same branch pre-seeds `VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/fly/README.md` at line 90, Typo in the README description: replace the
word "preseeds" with "pre-seeds" (or "prepopulates") in the sentence that
references VIEWER_ALLOWED_HOSTS
(`VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`) so the
allowlist description reads clearly (e.g., "pre-seeds VIEWER_ALLOWED_HOSTS..."
or "prepopulates VIEWER_ALLOWED_HOSTS...").

which are the Host headers `fly proxy 3113:3113` actually emits on
your laptop.

When `AGENTMEMORY_VIEWER_HOST` is non-loopback the viewer enforces two
extra guards: it refuses to start unless `VIEWER_ALLOWED_HOSTS` is
explicitly set, and every request to `/agentmemory/*` must present
`Authorization: Bearer $AGENTMEMORY_SECRET`. Static HTML and the
favicon are still served unauthenticated. If a proxied viewer request
gets a 401, the browser UI prompts for `AGENTMEMORY_SECRET` and stores
it in session storage so subsequent viewer API calls include the bearer.
Use the value printed in the first-boot logs or read `/data/.hmac`
inside the machine.

> **Security warning.** Setting `AGENTMEMORY_VIEWER_HOST=0.0.0.0` or
> `::` turns the viewer into a network-reachable proxy that signs every
> upstream call with `AGENTMEMORY_SECRET`. Never enable that outside a
> network you trust (Fly's WireGuard mesh in this template), and never
> set it in a plain `docker run -p 3113:3113 …` on a shared host — the
> entrypoint deliberately skips the override when Fly env vars are
> absent so a plain Docker pull stays loopback-only.

## Rotate the HMAC secret

```bash
Expand Down
14 changes: 14 additions & 0 deletions deploy/fly/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,18 @@ fi
AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")"
export AGENTMEMORY_SECRET

# The viewer's default 127.0.0.1 bind is unreachable through fly proxy,
# which enters the machine via fly-local-6pn (IPv6). Opt into a
# non-loopback bind ONLY when we're actually inside Fly (detected via
# Fly's runtime variables). A plain `docker run` of this image will not
# see these variables and will keep the safe-by-default loopback bind,
# so it can't silently expose the viewer's bearer-authorized proxy to
# the LAN. VIEWER_ALLOWED_HOSTS is preseeded to the Host headers that
# `fly proxy 3113:3113` actually produces on the operator's laptop.
if [ -n "${FLY_APP_NAME:-}" ] || [ -n "${FLY_ALLOC_ID:-}" ]; then
: "${AGENTMEMORY_VIEWER_HOST:=::}"
: "${VIEWER_ALLOWED_HOSTS:=localhost:3113,127.0.0.1:3113,[::1]:3113}"
export AGENTMEMORY_VIEWER_HOST VIEWER_ALLOWED_HOSTS
fi

exec gosu "$RUN_AS" agentmemory "$@"
92 changes: 92 additions & 0 deletions src/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,46 @@
.flag-close:hover,
.flag-close:focus-visible { color: var(--ink); outline: 2px solid var(--border); outline-offset: 1px; }

.viewer-auth {
display: none;
padding: 0 24px 10px 24px;
background: var(--bg);
flex: 0 0 auto;
position: relative;
z-index: 1;
}
.viewer-auth.open { display: block; }
.viewer-auth-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto;
gap: 10px;
align-items: center;
width: min(960px, 100%);
max-width: 960px;
padding: 10px 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
background: var(--bg-subtle);
font-family: var(--font-ui);
font-size: 12px;
}
.viewer-auth-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; }
.viewer-auth-desc { color: var(--ink-muted); line-height: 1.4; }
.viewer-auth-desc code { font-family: var(--font-mono); font-size: 10px; color: var(--ink); }
.viewer-auth input {
width: 100%;
min-width: 0;
padding: 7px 9px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12px;
}
@media (max-width: 900px) {
.viewer-auth-panel { grid-template-columns: 1fr; }
}

/* Viewer footer */
.viewer-footer {
margin-top: 48px; padding: 16px 0 24px;
Expand Down Expand Up @@ -961,6 +1001,7 @@ <h1>agentmemory</h1>
</div>

<div id="flag-banners" class="flag-banners"></div>
<div id="viewer-auth" class="viewer-auth"></div>

<div id="view-dashboard" class="view active"></div>
<div id="view-graph" class="view"></div>
Expand Down Expand Up @@ -1063,6 +1104,7 @@ <h1>agentmemory</h1>
};
var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
var TAB_IDS = ['dashboard', 'graph', 'memories', 'timeline', 'sessions', 'lessons', 'actions', 'crystals', 'audit', 'activity', 'profile', 'replay'];
var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token';

var state = {
activeTab: 'dashboard',
Expand Down Expand Up @@ -1159,17 +1201,54 @@ <h1>agentmemory</h1>
try { el.setSelectionRange(focus.start, focus.end); } catch (e) {}
}
}
function getViewerToken() {
try { return sessionStorage.getItem(VIEWER_TOKEN_STORAGE_KEY) || ''; } catch (_) { return ''; }
}
function setViewerToken(token) {
try {
if (token) sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, token);
else sessionStorage.removeItem(VIEWER_TOKEN_STORAGE_KEY);
} catch (_) {}
}
function showViewerAuthPrompt() {
var host = document.getElementById('viewer-auth');
if (!host) return;
host.classList.add('open');
host.innerHTML =
'<div class="viewer-auth-panel">' +
'<div>' +
'<div class="viewer-auth-title">Viewer authorization required</div>' +
'<div class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' +
'</div>' +
'<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" placeholder="AGENTMEMORY_SECRET" />' +
'<button class="btn" data-action="save-viewer-token">Unlock</button>' +
Comment on lines +1217 to +1224
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add an accessible name to the unlock field.

This input is the only path through the non-loopback auth gate, but it currently relies on placeholder text alone. Screen readers won't get a reliable label here, which makes the unlock flow much harder to complete.

♿ Proposed fix
-            '<div class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' +
+            '<div id="viewer-auth-desc" class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' +
           '</div>' +
-          '<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" placeholder="AGENTMEMORY_SECRET" />' +
+          '<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" aria-label="AGENTMEMORY_SECRET" aria-describedby="viewer-auth-desc" placeholder="AGENTMEMORY_SECRET" />' +
           '<button class="btn" data-action="save-viewer-token">Unlock</button>' +
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
host.innerHTML =
'<div class="viewer-auth-panel">' +
'<div>' +
'<div class="viewer-auth-title">Viewer authorization required</div>' +
'<div class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' +
'</div>' +
'<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" placeholder="AGENTMEMORY_SECRET" />' +
'<button class="btn" data-action="save-viewer-token">Unlock</button>' +
host.innerHTML =
'<div class="viewer-auth-panel">' +
'<div>' +
'<div class="viewer-auth-title">Viewer authorization required</div>' +
'<div id="viewer-auth-desc" class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' +
'</div>' +
'<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" aria-label="AGENTMEMORY_SECRET" aria-describedby="viewer-auth-desc" placeholder="AGENTMEMORY_SECRET" />' +
'<button class="btn" data-action="save-viewer-token">Unlock</button>' +
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/viewer/index.html` around lines 1217 - 1224, The unlock password input
(id="viewer-auth-token") lacks an accessible name; add one by either adding a
visible <label> that references the input id or by adding an
aria-label/aria-labelledby that conveys "Viewer authorization token" (or
similar) so screen readers can announce the field; update the host.innerHTML
where the input is created and ensure any visually-hidden label uses an
appropriate class if you want it hidden, and keep the button with
data-action="save-viewer-token" intact to preserve behavior.

'</div>';
var input = document.getElementById('viewer-auth-token');
if (input && typeof input.focus === 'function') input.focus();
}
function hideViewerAuthPrompt() {
var host = document.getElementById('viewer-auth');
if (!host) return;
host.classList.remove('open');
host.innerHTML = '';
}

async function api(path, opts) {
try {
var url = REST + '/agentmemory/' + path;
var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {});
var viewerToken = getViewerToken();
if (viewerToken && !headers.Authorization && !headers.authorization) {
headers.Authorization = 'Bearer ' + viewerToken;
}
var fetchOpts = Object.assign({}, opts || {}, { headers: headers });
var res = await fetch(url, fetchOpts);
if (!res.ok) {
if (res.status === 401) showViewerAuthPrompt();
console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status);
return null;
}
hideViewerAuthPrompt();
return await res.json();
} catch (err) {
console.warn('[viewer] API error on ' + path + ':', err);
Expand Down Expand Up @@ -3710,6 +3789,19 @@ <h1>agentmemory</h1>
if (memoryId) confirmDeleteMemory(memoryId);
return;
}
if (action === 'save-viewer-token') {
var tokenInput = document.getElementById('viewer-auth-token');
var token = tokenInput ? tokenInput.value.trim() : '';
if (token) {
setViewerToken(token);
hideViewerAuthPrompt();
if (state[state.activeTab] && typeof state[state.activeTab] === 'object') {
state[state.activeTab].loaded = false;
}
loadTab(state.activeTab);
}
return;
Comment on lines +3792 to +3803
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Retry the viewer config fetch after unlocking.

fetchFlags() only runs during bootstrap. If config/flags is the request that first 401s, this handler unlocks the active tab but leaves the footer version and flag banners stale until the user fully reloads the page.

🔁 Proposed fix
       if (token) {
         setViewerToken(token);
         hideViewerAuthPrompt();
+        fetchFlags();
         if (state[state.activeTab] && typeof state[state.activeTab] === 'object') {
           state[state.activeTab].loaded = false;
         }
         loadTab(state.activeTab);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (action === 'save-viewer-token') {
var tokenInput = document.getElementById('viewer-auth-token');
var token = tokenInput ? tokenInput.value.trim() : '';
if (token) {
setViewerToken(token);
hideViewerAuthPrompt();
if (state[state.activeTab] && typeof state[state.activeTab] === 'object') {
state[state.activeTab].loaded = false;
}
loadTab(state.activeTab);
}
return;
if (action === 'save-viewer-token') {
var tokenInput = document.getElementById('viewer-auth-token');
var token = tokenInput ? tokenInput.value.trim() : '';
if (token) {
setViewerToken(token);
hideViewerAuthPrompt();
fetchFlags();
if (state[state.activeTab] && typeof state[state.activeTab] === 'object') {
state[state.activeTab].loaded = false;
}
loadTab(state.activeTab);
}
return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/viewer/index.html` around lines 3792 - 3803, After successfully saving
the viewer token in the 'save-viewer-token' handler (around setViewerToken,
hideViewerAuthPrompt, loadTab(state.activeTab)), also invoke fetchFlags() to
re-fetch config/flags and then refresh the footer/version and flag banner UI
(e.g., call existing functions like refreshFooterVersion() and
refreshFlagBanners(), or implement small functions to re-render those parts if
they don't exist) so the page state updates immediately without a full reload.

}
if (action === 'timeline-filter') {
setTlTypeFilter(target.getAttribute('data-type-filter') || '');
return;
Expand Down
Loading