hermes: restore terminal rendering and worker lineage
Some checks failed
Tests / Declarative: Post Actions testing.tests.test_hermes_chat_quality.test_compact_image_edit_resolves_latest_tenant_artifact failed
Some checks failed
Tests / Declarative: Post Actions testing.tests.test_hermes_chat_quality.test_compact_image_edit_resolves_latest_tenant_artifact failed
This commit is contained in:
parent
1e5ad40cfb
commit
009336fb64
@ -685,6 +685,344 @@ for before, after, label in replacements:
|
|||||||
path.write_text(source)
|
path.write_text(source)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
# Keep the browser terminal reliable across GPU context loss, make durable
|
||||||
|
# worker lineage visible, and expose the family Telegram-linking entry point.
|
||||||
|
RUN node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
function replaceOnce(source, before, after, label) {
|
||||||
|
const count = source.split(before).length - 1;
|
||||||
|
if (count !== 1) {
|
||||||
|
throw new Error(`${label} patch context changed: expected 1, found ${count}`);
|
||||||
|
}
|
||||||
|
return source.replace(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A WebGL context can be discarded while a browser tab sleeps or loses its
|
||||||
|
// GPU process. xterm's canvas renderer is fast enough for this single PTY and
|
||||||
|
// can be repainted deterministically from the server-side replay buffer.
|
||||||
|
{
|
||||||
|
const path = "/opt/hermes/web/src/pages/ChatPage.tsx";
|
||||||
|
let source = fs.readFileSync(path, "utf8");
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
'import { WebglAddon } from "@xterm/addon-webgl";\n',
|
||||||
|
"",
|
||||||
|
"xterm WebGL import",
|
||||||
|
);
|
||||||
|
const webglStart = source.indexOf(" // WebGL draws from");
|
||||||
|
const webglEnd = source.indexOf(
|
||||||
|
"\n\n // Initial fit + resize observer",
|
||||||
|
webglStart,
|
||||||
|
);
|
||||||
|
if (webglStart < 0 || webglEnd < 0) {
|
||||||
|
throw new Error("xterm WebGL setup patch context changed");
|
||||||
|
}
|
||||||
|
source =
|
||||||
|
source.slice(0, webglStart) +
|
||||||
|
` // Use xterm's canvas renderer. It survives browser sleep and GPU-process
|
||||||
|
// resets, while the server-side PTY replay restores any missed output.
|
||||||
|
` +
|
||||||
|
source.slice(webglEnd + 2);
|
||||||
|
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
" term.open(host);\n",
|
||||||
|
` term.open(host);
|
||||||
|
|
||||||
|
// Coalesce terminal repaints so buffered replay and visibility changes
|
||||||
|
// cannot leave a valid xterm buffer hidden behind a stale renderer frame.
|
||||||
|
let paintRaf = 0;
|
||||||
|
const scheduleTerminalPaint = () => {
|
||||||
|
if (paintRaf) return;
|
||||||
|
paintRaf = requestAnimationFrame(() => {
|
||||||
|
paintRaf = 0;
|
||||||
|
if (!host.isConnected || term.rows <= 0) return;
|
||||||
|
try {
|
||||||
|
term.refresh(0, term.rows - 1);
|
||||||
|
} catch {
|
||||||
|
/* terminal disposed during a navigation */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const refreshVisibleTerminal = () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
syncMetricsRef.current?.();
|
||||||
|
scheduleTerminalPaint();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", refreshVisibleTerminal);
|
||||||
|
`,
|
||||||
|
"xterm repaint scheduler",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n',
|
||||||
|
' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n' +
|
||||||
|
" scheduleTerminalPaint();\n",
|
||||||
|
"xterm open repaint",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` ws.onmessage = (ev) => {
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
term.write(ev.data);
|
||||||
|
} else {
|
||||||
|
term.write(new Uint8Array(ev.data as ArrayBuffer));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
` ws.onmessage = (ev) => {
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
term.write(ev.data, scheduleTerminalPaint);
|
||||||
|
} else {
|
||||||
|
term.write(new Uint8Array(ev.data as ArrayBuffer), scheduleTerminalPaint);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
"xterm replay repaint",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
" ro.disconnect();\n",
|
||||||
|
` ro.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", refreshVisibleTerminal);
|
||||||
|
if (paintRaf) cancelAnimationFrame(paintRaf);
|
||||||
|
`,
|
||||||
|
"xterm repaint cleanup",
|
||||||
|
);
|
||||||
|
source = source.replace(
|
||||||
|
"@xterm/xterm Terminal (WebGL renderer, Unicode 11 widths)",
|
||||||
|
"@xterm/xterm Terminal (repaintable canvas, Unicode 11 widths)",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API already stores parent_session_id. Present it as a collapsible tree
|
||||||
|
// instead of making durable Codex/Claude/API workers look like root chats.
|
||||||
|
{
|
||||||
|
const path = "/opt/hermes/web/src/components/ChatSessionList.tsx";
|
||||||
|
let source = fs.readFileSync(path, "utf8");
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
'import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";',
|
||||||
|
'import { AlertCircle, ChevronDown, ChevronRight, GitBranch, MessageSquarePlus, RefreshCw } from "lucide-react";',
|
||||||
|
"session-tree icons",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
"const SESSION_LIMIT = 30;",
|
||||||
|
"const SESSION_LIMIT = 100;",
|
||||||
|
"session-tree list limit",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
" const [reloadNonce, setReloadNonce] = useState(0);\n",
|
||||||
|
` const [reloadNonce, setReloadNonce] = useState(0);
|
||||||
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
"session-tree expansion state",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
" const content = useMemo(() => {\n",
|
||||||
|
` const sessionTree = useMemo(() => {
|
||||||
|
const listed = sessions ?? [];
|
||||||
|
const sessionIds = new Set(listed.map((session) => session.id));
|
||||||
|
const childrenByParent = new Map<string, SessionInfo[]>();
|
||||||
|
const roots: SessionInfo[] = [];
|
||||||
|
for (const session of listed) {
|
||||||
|
const parent = session.parent_session_id;
|
||||||
|
if (parent && sessionIds.has(parent)) {
|
||||||
|
const children = childrenByParent.get(parent) ?? [];
|
||||||
|
children.push(session);
|
||||||
|
childrenByParent.set(parent, children);
|
||||||
|
} else {
|
||||||
|
roots.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { childrenByParent, roots };
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
const active = sessions?.find((session) => session.id === activeSessionId);
|
||||||
|
if (!active?.parent_session_id) return;
|
||||||
|
setExpandedParents((previous) => {
|
||||||
|
if (previous.has(active.parent_session_id!)) return previous;
|
||||||
|
const next = new Set(previous);
|
||||||
|
next.add(active.parent_session_id!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [activeSessionId, sessions]);
|
||||||
|
|
||||||
|
const toggleParent = useCallback((id: string) => {
|
||||||
|
setExpandedParents((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
`,
|
||||||
|
"session-tree derivation",
|
||||||
|
);
|
||||||
|
const listStart = source.indexOf(" {sessions.map((s) => {");
|
||||||
|
const listEndMarker = " })}\n </div>";
|
||||||
|
const listEnd = source.indexOf(listEndMarker, listStart);
|
||||||
|
if (listStart < 0 || listEnd < 0) {
|
||||||
|
throw new Error("session-tree list rendering patch context changed");
|
||||||
|
}
|
||||||
|
const listReplacement = String.raw` {sessionTree.roots.flatMap((root) => {
|
||||||
|
const children = sessionTree.childrenByParent.get(root.id) ?? [];
|
||||||
|
const rows: Array<{ session: SessionInfo; nested: boolean }> = [
|
||||||
|
{ session: root, nested: false },
|
||||||
|
];
|
||||||
|
if (expandedParents.has(root.id)) {
|
||||||
|
rows.push(...children.map((session) => ({ session, nested: true })));
|
||||||
|
}
|
||||||
|
return rows.map(({ session: s, nested }) => {
|
||||||
|
const isActive = s.id === activeSessionId;
|
||||||
|
const childCount = sessionTree.childrenByParent.get(s.id)?.length ?? 0;
|
||||||
|
const expanded = expandedParents.has(s.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-stretch gap-0.5",
|
||||||
|
nested && "ml-4 border-l border-current/15 pl-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{childCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={expanded ? "Collapse worker sessions" : "Expand worker sessions"}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
title={childCount + " worker session" + (childCount === 1 ? "" : "s")}
|
||||||
|
onClick={() => toggleParent(s.id)}
|
||||||
|
className="flex w-5 shrink-0 items-center justify-center text-text-tertiary hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
) : nested ? (
|
||||||
|
<span className="flex w-5 shrink-0 items-center justify-center text-text-tertiary" aria-hidden>
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<ListItem
|
||||||
|
onClick={() => pick(s.id)}
|
||||||
|
aria-current={isActive ? "true" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 flex-col items-start gap-0.5 rounded px-2 py-1.5",
|
||||||
|
"normal-case tracking-normal",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-foreground border-l-2 border-primary"
|
||||||
|
: "text-text-secondary hover:bg-midground/5 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex w-full items-center gap-1 truncate text-sm font-medium">
|
||||||
|
<span className="truncate">{rowLabel(s, t.sessions.untitledSession)}</span>
|
||||||
|
{childCount > 0 && (
|
||||||
|
<span className="shrink-0 text-[0.625rem] text-text-tertiary">
|
||||||
|
{childCount} workers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex w-full items-center gap-1.5 text-[0.6875rem] text-text-tertiary">
|
||||||
|
<span>{timeAgo(s.last_active)}</span>
|
||||||
|
{s.message_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>{s.message_count} msgs</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{s.source && s.source !== "cli" && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="truncate">{s.source}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ListItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}`;
|
||||||
|
source =
|
||||||
|
source.slice(0, listStart) +
|
||||||
|
listReplacement +
|
||||||
|
source.slice(listEnd + " })}".length);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
" }, [activeSessionId, error, loading, pick, reload, sessions, t]);",
|
||||||
|
" }, [activeSessionId, error, expandedParents, loading, pick, reload, sessionTree, sessions, t, toggleParent]);",
|
||||||
|
"session-tree memo dependencies",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram has one operator-managed bot, but every Keycloak user links their
|
||||||
|
// own account. Put that workflow in normal navigation instead of a floating
|
||||||
|
// button or a URL users have to remember.
|
||||||
|
{
|
||||||
|
const path = "/opt/hermes/web/src/App.tsx";
|
||||||
|
let source = fs.readFileSync(path, "utf8");
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
"function RootRedirect() {\n",
|
||||||
|
`function TelegramSetupPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5 p-6 lg:p-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Telegram</h2>
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
|
Atlas uses one operator-managed Hermes bot. Each Keycloak user links
|
||||||
|
their own Telegram account once; conversations remain attached to
|
||||||
|
that user's isolated family-chat workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-current/20 bg-midground/5 p-5">
|
||||||
|
<h3 className="text-sm font-bold tracking-wider">Link this account</h3>
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
|
Open the family-chat linking page, create a one-time code, then send
|
||||||
|
it to the shared Hermes bot from your Telegram account.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://chat.hermes.bstein.dev/telegram"
|
||||||
|
className="mt-4 inline-flex rounded border border-current/30 px-4 py-2 text-sm font-medium text-midground hover:bg-midground/10"
|
||||||
|
>
|
||||||
|
Open Telegram setup
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootRedirect() {
|
||||||
|
`,
|
||||||
|
"Telegram setup page",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
' "/channels": ChannelsPage,\n',
|
||||||
|
' "/channels": ChannelsPage,\n "/telegram": TelegramSetupPage,\n',
|
||||||
|
"Telegram setup route",
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
' { path: "/channels", label: "Channels", icon: Radio },\n',
|
||||||
|
' { path: "/channels", label: "Channels", icon: Radio },\n { path: "/telegram", label: "Telegram", icon: MessageSquare },\n',
|
||||||
|
"Telegram setup navigation",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path, source);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
|
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
|
||||||
|
|
||||||
RUN cd /opt/hermes/web \
|
RUN cd /opt/hermes/web \
|
||||||
@ -711,6 +1049,11 @@ RUN cd /opt/hermes/web \
|
|||||||
/opt/hermes/hermes_cli/web_server.py \
|
/opt/hermes/hermes_cli/web_server.py \
|
||||||
&& grep -Fq 'Claude Code subscription (Switchyard)' \
|
&& grep -Fq 'Claude Code subscription (Switchyard)' \
|
||||||
/opt/hermes/hermes_cli/web_server.py \
|
/opt/hermes/hermes_cli/web_server.py \
|
||||||
|
&& ! grep -Fq 'WebglAddon' src/pages/ChatPage.tsx \
|
||||||
|
&& grep -Fq 'scheduleTerminalPaint' src/pages/ChatPage.tsx \
|
||||||
|
&& grep -Fq 'sessionTree.childrenByParent' \
|
||||||
|
src/components/ChatSessionList.tsx \
|
||||||
|
&& grep -Fq 'Open Telegram setup' src/App.tsx \
|
||||||
&& grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \
|
&& grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \
|
||||||
&& grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \
|
&& grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \
|
||||||
&& grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \
|
&& grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \
|
||||||
|
|||||||
@ -25,7 +25,7 @@ spec:
|
|||||||
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
|
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
|
||||||
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
||||||
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
||||||
ai.bstein.dev/config-rev: "20260812-provider-history-lineage-cleanup"
|
ai.bstein.dev/config-rev: "20260813-terminal-lineage-oauth"
|
||||||
vault.hashicorp.com/agent-inject: "true"
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
vault.hashicorp.com/role: hermes-agent
|
vault.hashicorp.com/role: hermes-agent
|
||||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
||||||
@ -521,6 +521,7 @@ spec:
|
|||||||
- --scope=openid profile email
|
- --scope=openid profile email
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
|
- --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
|
||||||
|
- --custom-templates-dir=/etc/oauth2-proxy/templates
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
- --pass-user-headers=true
|
- --pass-user-headers=true
|
||||||
- --pass-basic-auth=false
|
- --pass-basic-auth=false
|
||||||
@ -567,6 +568,7 @@ spec:
|
|||||||
limits: {cpu: 250m, memory: 256Mi}
|
limits: {cpu: 250m, memory: 256Mi}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
|
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
|
||||||
|
- {name: oauth-templates, mountPath: /etc/oauth2-proxy/templates, readOnly: true}
|
||||||
- {name: oauth-tmp, mountPath: /tmp}
|
- {name: oauth-tmp, mountPath: /tmp}
|
||||||
- name: terminal
|
- name: terminal
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
@ -942,6 +944,9 @@ spec:
|
|||||||
- name: allowlist
|
- name: allowlist
|
||||||
configMap:
|
configMap:
|
||||||
name: hermes-owner-allowlist
|
name: hermes-owner-allowlist
|
||||||
|
- name: oauth-templates
|
||||||
|
configMap:
|
||||||
|
name: hermes-chat-oauth-templates
|
||||||
- name: ttyd-index
|
- name: ttyd-index
|
||||||
emptyDir:
|
emptyDir:
|
||||||
sizeLimit: 2Mi
|
sizeLimit: 2Mi
|
||||||
|
|||||||
@ -4,7 +4,7 @@ kind: Kustomization
|
|||||||
namespace: hermes
|
namespace: hermes
|
||||||
images:
|
images:
|
||||||
- name: registry.bstein.dev/bstein/hermes-agent
|
- name: registry.bstein.dev/bstein/hermes-agent
|
||||||
digest: sha256:ce106ccb408684677176cc9477b785fbbe5b6171427c4931b86dac7f51d5c2b3
|
digest: sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- vault-serviceaccount.yaml
|
- vault-serviceaccount.yaml
|
||||||
|
|||||||
@ -5,7 +5,36 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>{{.StatusCode}} {{.Title}}</title>
|
<title>{{.StatusCode}} {{.Title}}</title>
|
||||||
{{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}<meta http-equiv="refresh" content="0;url={{.ProxyPrefix}}/sign_in?rd=/">{{end}}
|
{{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const retryKey = "hermes.oauth.callback-recovery";
|
||||||
|
const retryWindowMs = 60_000;
|
||||||
|
let shouldRetry = false;
|
||||||
|
try {
|
||||||
|
const previous = Number(window.sessionStorage.getItem(retryKey) || "0");
|
||||||
|
const now = Date.now();
|
||||||
|
shouldRetry = !previous || now - previous > retryWindowMs;
|
||||||
|
if (shouldRetry) window.sessionStorage.setItem(retryKey, String(now));
|
||||||
|
} catch {
|
||||||
|
// Storage-disabled browsers keep the manual recovery link below.
|
||||||
|
}
|
||||||
|
if (shouldRetry) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.replace("{{.ProxyPrefix}}/start?rd=/");
|
||||||
|
}, 350);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const detail = document.getElementById("recovery-detail");
|
||||||
|
if (detail) {
|
||||||
|
detail.textContent =
|
||||||
|
"Automatic recovery paused to avoid a sign-in loop. Continue once to start a fresh login.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
<style>
|
<style>
|
||||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f5f5;color:#333;font:16px/1.5 system-ui,sans-serif}
|
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f5f5;color:#333;font:16px/1.5 system-ui,sans-serif}
|
||||||
main{width:min(560px,calc(100% - 40px));box-sizing:border-box;padding:36px;border:1px solid #ddd;border-radius:14px;background:#fff;text-align:center;box-shadow:0 12px 45px #0002}
|
main{width:min(560px,calc(100% - 40px));box-sizing:border-box;padding:36px;border:1px solid #ddd;border-radius:14px;background:#fff;text-align:center;box-shadow:0 12px 45px #0002}
|
||||||
@ -16,12 +45,12 @@
|
|||||||
<main>
|
<main>
|
||||||
{{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}
|
{{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}
|
||||||
<h1>Signing you back in…</h1>
|
<h1>Signing you back in…</h1>
|
||||||
<p class="detail">The previous one-time login callback expired or was already used. Hermes is starting a fresh sign-in automatically.</p>
|
<p id="recovery-detail" class="detail">The previous one-time login callback expired or was already used. Hermes is starting one fresh sign-in automatically.</p>
|
||||||
<a href="{{.ProxyPrefix}}/sign_in?rd=/">Continue now</a>
|
<a href="{{.ProxyPrefix}}/start?rd=/">Continue now</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h1>{{.StatusCode}} {{.Title}}</h1>
|
<h1>{{.StatusCode}} {{.Title}}</h1>
|
||||||
{{if .Message}}<p class="detail">{{.Message}}</p>{{end}}
|
{{if .Message}}<p class="detail">{{.Message}}</p>{{end}}
|
||||||
<a href="{{.ProxyPrefix}}/sign_in?rd=/">Sign in again</a>
|
<a href="{{.ProxyPrefix}}/start?rd=/">Sign in again</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -153,6 +153,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
|||||||
assert '"source": "managed_claude_code_subscription"' in dockerfile
|
assert '"source": "managed_claude_code_subscription"' in dockerfile
|
||||||
assert '"name": "Claude Code subscription (Switchyard)"' in dockerfile
|
assert '"name": "Claude Code subscription (Switchyard)"' in dockerfile
|
||||||
assert "Anthropic API / direct OAuth (not used by Switchyard)" in dockerfile
|
assert "Anthropic API / direct OAuth (not used by Switchyard)" in dockerfile
|
||||||
|
assert "scheduleTerminalPaint" in dockerfile
|
||||||
|
assert "sessionTree.childrenByParent" in dockerfile
|
||||||
|
assert "Open Telegram setup" in dockerfile
|
||||||
|
|
||||||
|
|
||||||
def test_chat_oauth_allows_stale_service_worker_retirement():
|
def test_chat_oauth_allows_stale_service_worker_retirement():
|
||||||
@ -169,10 +172,12 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
|
|||||||
assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in args
|
assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in args
|
||||||
|
|
||||||
template = (HERMES / "oauth2-proxy-templates" / "error.html").read_text()
|
template = (HERMES / "oauth2-proxy-templates" / "error.html").read_text()
|
||||||
assert 'http-equiv="refresh"' in template
|
assert 'http-equiv="refresh"' not in template
|
||||||
assert "Unable to find a valid CSRF token" in template
|
assert "Unable to find a valid CSRF token" in template
|
||||||
assert "expired or was already used" in template
|
assert "expired or was already used" in template
|
||||||
assert "/sign_in?rd=/" in template
|
assert "sessionStorage" in template
|
||||||
|
assert "Automatic recovery paused" in template
|
||||||
|
assert "/start?rd=/" in template
|
||||||
container = deployment["spec"]["template"]["spec"]["containers"][0]
|
container = deployment["spec"]["template"]["spec"]["containers"][0]
|
||||||
assert "v7.15.3@sha256:10a1165743a192e" in container["image"]
|
assert "v7.15.3@sha256:10a1165743a192e" in container["image"]
|
||||||
assert "--cookie-csrf-per-request=true" in args
|
assert "--cookie-csrf-per-request=true" in args
|
||||||
@ -188,6 +193,26 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
|
|||||||
for arg in args
|
for arg in args
|
||||||
)
|
)
|
||||||
|
|
||||||
|
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
||||||
|
agent_pod = agent["spec"]["template"]["spec"]
|
||||||
|
agent_oauth = next(
|
||||||
|
container for container in agent_pod["containers"]
|
||||||
|
if container["name"] == "oauth2-proxy"
|
||||||
|
)
|
||||||
|
assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in agent_oauth["args"]
|
||||||
|
assert {
|
||||||
|
"name": "oauth-templates",
|
||||||
|
"mountPath": "/etc/oauth2-proxy/templates",
|
||||||
|
"readOnly": True,
|
||||||
|
} in agent_oauth["volumeMounts"]
|
||||||
|
agent_template_volume = next(
|
||||||
|
volume for volume in agent_pod["volumes"]
|
||||||
|
if volume["name"] == "oauth-templates"
|
||||||
|
)
|
||||||
|
assert agent_template_volume["configMap"]["name"] == (
|
||||||
|
"hermes-chat-oauth-templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
||||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user