Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
+188
-45
@@ -97,6 +97,12 @@
|
||||
const API = "/api/plugins/kanban";
|
||||
const MIME_TASK = "text/x-hermes-task";
|
||||
|
||||
// Docs link — surfaced as a `?` icon next to the board switcher and as
|
||||
// `title=` hints on unlabelled controls. Kept in one place so rebrands or
|
||||
// path changes are a single edit.
|
||||
const DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban";
|
||||
const DOCS_TUTORIAL_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban-tutorial";
|
||||
|
||||
// localStorage key for the user's selected board. Independent of the
|
||||
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
|
||||
// can inspect any board without shifting the CLI's active board out
|
||||
@@ -112,17 +118,30 @@
|
||||
|
||||
function writeSelectedBoard(slug) {
|
||||
try {
|
||||
if (slug && slug !== "default") window.localStorage.setItem(LS_BOARD_KEY, slug);
|
||||
// Persist the user's dashboard-side board pin even for "default".
|
||||
// Previously this stripped "default" to keep localStorage empty,
|
||||
// but the fetch layer read that absence as "no opinion" and fell
|
||||
// through to the server-side ``current`` file — which the board
|
||||
// switcher also writes. Result: selecting the default tab after
|
||||
// creating a new board with "switch" checked showed the new
|
||||
// board's (wrong) data because the URL omitted ``?board=`` and
|
||||
// the backend happily returned whichever board was "current".
|
||||
// Persisting every selection keeps the dashboard's board opinion
|
||||
// independent of the CLI's active board, which was the original
|
||||
// design intent. Regression: #20879.
|
||||
if (slug) window.localStorage.setItem(LS_BOARD_KEY, slug);
|
||||
else window.localStorage.removeItem(LS_BOARD_KEY);
|
||||
} catch (_e) { /* ignore quota / private mode */ }
|
||||
}
|
||||
|
||||
function withBoard(url, board) {
|
||||
// Append ?board=<slug> when a non-default board is active. Omitted
|
||||
// for default so the URL stays clean and the backend falls through
|
||||
// to its own resolution chain (env var → ``current`` file →
|
||||
// default) which is already correct.
|
||||
if (!board || board === "default") return url;
|
||||
// Always append ?board=<slug> when we have one picked — including
|
||||
// "default". Omitting the param would fall through to the backend's
|
||||
// resolution chain (env var → ``current`` file → default), which
|
||||
// means the dashboard's tab selection gets silently overridden by
|
||||
// whatever board the CLI or "switch" checkbox last activated.
|
||||
// Regression: #20879.
|
||||
if (!board) return url;
|
||||
const sep = url.indexOf("?") >= 0 ? "&" : "?";
|
||||
return `${url}${sep}board=${encodeURIComponent(board)}`;
|
||||
}
|
||||
@@ -447,9 +466,11 @@
|
||||
token: token,
|
||||
};
|
||||
// Pin the WS stream to the currently-selected board so events
|
||||
// from other boards don't bleed in. Only set for non-default so
|
||||
// single-board installs keep the cleaner URL.
|
||||
if (board && board !== "default") qsParams.board = board;
|
||||
// from other boards don't bleed in. Includes "default" so the
|
||||
// dashboard's own board pin always wins over the server-side
|
||||
// ``current`` file — same rationale as ``withBoard()`` above.
|
||||
// Regression: #20879.
|
||||
if (board) qsParams.board = board;
|
||||
const qs = new URLSearchParams(qsParams);
|
||||
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
||||
let ws;
|
||||
@@ -496,6 +517,7 @@
|
||||
if (!boardData) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
const filterTask = function (t) {
|
||||
if (tenantFilter && t.tenant !== tenantFilter) return false;
|
||||
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
|
||||
if (q) {
|
||||
const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase();
|
||||
@@ -508,7 +530,7 @@
|
||||
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
|
||||
}),
|
||||
});
|
||||
}, [boardData, assigneeFilter, search]);
|
||||
}, [boardData, tenantFilter, assigneeFilter, search]);
|
||||
|
||||
// --- actions ------------------------------------------------------------
|
||||
const moveTask = useCallback(function (taskId, newStatus) {
|
||||
@@ -1112,6 +1134,20 @@
|
||||
// Board switcher (multi-project)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Small `?` affordance next to the board controls. Opens the kanban docs
|
||||
// page in a new tab so users can look up what any of the widgets mean
|
||||
// without losing the current board view.
|
||||
function DocsLink() {
|
||||
return h("a", {
|
||||
href: DOCS_URL,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
className: "hermes-kanban-docs-link",
|
||||
title: "Open Hermes Kanban docs in a new tab",
|
||||
"aria-label": "Hermes Kanban documentation",
|
||||
}, "?");
|
||||
}
|
||||
|
||||
function BoardSwitcher(props) {
|
||||
const list = props.boardList || [];
|
||||
const current = list.find(function (b) { return b.slug === props.board; });
|
||||
@@ -1136,6 +1172,7 @@
|
||||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
}, "+ New board"),
|
||||
h(DocsLink, null),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1149,6 +1186,7 @@
|
||||
value: props.board,
|
||||
className: "h-8 min-w-[220px]",
|
||||
"aria-label": "Switch kanban board",
|
||||
title: "Boards are independent work streams. Each board has its own tasks, tenants, and assignees.",
|
||||
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
|
||||
list.map(function (b) {
|
||||
const label = b.total > 0
|
||||
@@ -1162,10 +1200,12 @@
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(DocsLink, null),
|
||||
h(Button, {
|
||||
onClick: props.onNewClick,
|
||||
size: "sm",
|
||||
className: "h-8",
|
||||
title: "Create a new board. Useful when you want an unrelated work stream (different project, different team, isolated scratch area).",
|
||||
}, "+ New board"),
|
||||
props.board !== "default"
|
||||
? h(Button, {
|
||||
@@ -1310,7 +1350,8 @@
|
||||
const tenants = (props.board && props.board.tenants) || [];
|
||||
const assignees = (props.board && props.board.assignees) || [];
|
||||
return h("div", { className: "flex flex-wrap items-end gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h("div", { className: "flex flex-col gap-1",
|
||||
title: "Fuzzy-match tasks by id, title, or description. Matches across all columns." },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Search"),
|
||||
h(Input, {
|
||||
placeholder: "Filter cards…",
|
||||
@@ -1319,7 +1360,8 @@
|
||||
className: "w-56 h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h("div", { className: "flex flex-col gap-1",
|
||||
title: "Tenants are free-form tags on a task (e.g. customer, project, team). Set them via the task drawer or kanban_create." },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
||||
h(Select, Object.assign({
|
||||
value: props.tenantFilter,
|
||||
@@ -1331,7 +1373,8 @@
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h("div", { className: "flex flex-col gap-1",
|
||||
title: "Filter by assigned Hermes profile. Profiles are the named agent identities that claim and work on tasks." },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
||||
h(Select, Object.assign({
|
||||
value: props.assigneeFilter,
|
||||
@@ -1343,7 +1386,8 @@
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("label", { className: "flex items-center gap-2 text-xs" },
|
||||
h("label", { className: "flex items-center gap-2 text-xs",
|
||||
title: "Include archived tasks in the board view. Archived tasks are hidden by default." },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: props.includeArchived,
|
||||
@@ -1364,10 +1408,12 @@
|
||||
h(Button, {
|
||||
onClick: props.onNudgeDispatch,
|
||||
size: "sm",
|
||||
title: "Wake the dispatcher to claim ready tasks now instead of waiting for the next tick. Use this after adding tasks if you want them picked up immediately.",
|
||||
}, "Nudge dispatcher"),
|
||||
h(Button, {
|
||||
onClick: props.onRefresh,
|
||||
size: "sm",
|
||||
title: "Reload the board from the database. The board auto-refreshes on task events; this is for forcing a re-read.",
|
||||
}, "Refresh"),
|
||||
);
|
||||
}
|
||||
@@ -1384,6 +1430,7 @@
|
||||
h(Button, {
|
||||
onClick: function () { props.onApply({ status: "ready" }); },
|
||||
size: "sm",
|
||||
title: "Move selected tasks to Ready. Ready tasks are picked up by the dispatcher on the next tick.",
|
||||
}, "→ ready"),
|
||||
h(Button, {
|
||||
onClick: function () {
|
||||
@@ -1391,6 +1438,7 @@
|
||||
`Mark ${props.count} task(s) as done?`);
|
||||
},
|
||||
size: "sm",
|
||||
title: "Mark selected tasks as done. Releases any claims and unblocks dependent children. You'll be asked for a completion summary.",
|
||||
}, "Complete"),
|
||||
h(Button, {
|
||||
onClick: function () {
|
||||
@@ -1398,8 +1446,10 @@
|
||||
`Archive ${props.count} task(s)?`);
|
||||
},
|
||||
size: "sm",
|
||||
title: "Archive selected tasks. They disappear from the default board view but remain in the database.",
|
||||
}, "Archive"),
|
||||
h("div", { className: "hermes-kanban-bulk-reassign" },
|
||||
h("div", { className: "hermes-kanban-bulk-reassign",
|
||||
title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." },
|
||||
h(Select, {
|
||||
value: assignee,
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
@@ -1419,12 +1469,14 @@
|
||||
},
|
||||
disabled: !assignee,
|
||||
size: "sm",
|
||||
title: "Apply the selected assignee to all selected tasks.",
|
||||
}, "Apply"),
|
||||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(Button, {
|
||||
onClick: props.onClear,
|
||||
size: "sm",
|
||||
title: "Deselect all tasks and hide this bar.",
|
||||
}, "Clear"),
|
||||
);
|
||||
}
|
||||
@@ -1505,11 +1557,13 @@
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
h("div", { className: "hermes-kanban-column-header" },
|
||||
h("div", { className: "hermes-kanban-column-header",
|
||||
title: COLUMN_HELP[props.column.name] || "" },
|
||||
h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }),
|
||||
h("span", { className: "hermes-kanban-column-label" },
|
||||
COLUMN_LABEL[props.column.name] || props.column.name),
|
||||
h("span", { className: "hermes-kanban-column-count" },
|
||||
h("span", { className: "hermes-kanban-column-count",
|
||||
title: `${props.column.tasks.length} task${props.column.tasks.length === 1 ? "" : "s"} in this column` },
|
||||
props.column.tasks.length),
|
||||
h("button", {
|
||||
type: "button",
|
||||
@@ -1636,7 +1690,8 @@
|
||||
onClick: function (e) { e.stopPropagation(); },
|
||||
title: "Select for bulk actions",
|
||||
}),
|
||||
h("span", { className: "hermes-kanban-card-id" }, t.id),
|
||||
h("span", { className: "hermes-kanban-card-id",
|
||||
title: `Task id: ${t.id}. Use this id with kanban_show, /kanban show, or hermes kanban show.` }, t.id),
|
||||
t.warnings && t.warnings.count > 0
|
||||
? h("span", {
|
||||
className: cn(
|
||||
@@ -1653,10 +1708,12 @@
|
||||
t.warnings.highest_severity === "error" ? "!!" : "⚠")
|
||||
: null,
|
||||
t.priority > 0
|
||||
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
|
||||
? h(Badge, { className: "hermes-kanban-priority",
|
||||
title: `Priority ${t.priority}. Higher-priority tasks are claimed first by the dispatcher.` }, `P${t.priority}`)
|
||||
: null,
|
||||
t.tenant
|
||||
? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant)
|
||||
? h(Badge, { variant: "outline", className: "hermes-kanban-tag",
|
||||
title: `Tenant: ${t.tenant}. Free-form tag for grouping tasks (customer, project, team).` }, t.tenant)
|
||||
: null,
|
||||
progress
|
||||
? h("span", {
|
||||
@@ -1671,16 +1728,21 @@
|
||||
h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"),
|
||||
h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" },
|
||||
t.assignee
|
||||
? h("span", { className: "hermes-kanban-assignee" }, "@", t.assignee)
|
||||
: h("span", { className: "hermes-kanban-unassigned" }, "unassigned"),
|
||||
? h("span", { className: "hermes-kanban-assignee",
|
||||
title: `Assigned to Hermes profile @${t.assignee}` }, "@", t.assignee)
|
||||
: h("span", { className: "hermes-kanban-unassigned",
|
||||
title: "No profile assigned. The dispatcher will pick one from available profiles when the task is Ready." }, "unassigned"),
|
||||
t.comment_count > 0
|
||||
? h("span", { className: "hermes-kanban-count" }, "💬 ", t.comment_count)
|
||||
? h("span", { className: "hermes-kanban-count",
|
||||
title: `${t.comment_count} comment${t.comment_count === 1 ? "" : "s"} on this task` }, "💬 ", t.comment_count)
|
||||
: null,
|
||||
t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0
|
||||
? h("span", { className: "hermes-kanban-count" },
|
||||
? h("span", { className: "hermes-kanban-count",
|
||||
title: `${t.link_counts.parents} parent${t.link_counts.parents === 1 ? "" : "s"}, ${t.link_counts.children} child${t.link_counts.children === 1 ? "" : "ren"}. Children stay blocked until their parent is done.` },
|
||||
"↔ ", t.link_counts.parents + t.link_counts.children)
|
||||
: null,
|
||||
h("span", { className: "hermes-kanban-ago" },
|
||||
h("span", { className: "hermes-kanban-ago",
|
||||
title: t.created_at ? `Created ${t.created_at}` : "" },
|
||||
timeAgo ? timeAgo(t.created_at) : ""),
|
||||
),
|
||||
),
|
||||
@@ -1741,18 +1803,19 @@
|
||||
: "workspace path (optional, derived from assignee if blank)";
|
||||
|
||||
return h("div", { className: "hermes-kanban-inline-create" },
|
||||
h(Input, {
|
||||
h("textarea", {
|
||||
value: title,
|
||||
onChange: function (e) { setTitle(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); submit(); }
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
if (e.key === "Escape") props.onCancel();
|
||||
},
|
||||
placeholder: props.columnName === "triage"
|
||||
? "Rough idea — AI will spec it…"
|
||||
: "New task title…",
|
||||
autoFocus: true,
|
||||
className: "h-8 text-sm",
|
||||
className: "text-sm min-h-[2rem] max-h-32 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
rows: 2,
|
||||
}),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Input, {
|
||||
@@ -1760,6 +1823,9 @@
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
placeholder: props.columnName === "triage" ? "specifier" : "assignee",
|
||||
className: "h-7 text-xs flex-1",
|
||||
title: props.columnName === "triage"
|
||||
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
|
||||
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
|
||||
}),
|
||||
h(Input, {
|
||||
type: "number",
|
||||
@@ -1767,6 +1833,7 @@
|
||||
onChange: function (e) { setPriority(e.target.value); },
|
||||
placeholder: "pri",
|
||||
className: "h-7 text-xs w-16",
|
||||
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||
}),
|
||||
),
|
||||
h(Input, {
|
||||
@@ -1798,6 +1865,7 @@
|
||||
value: parent,
|
||||
onChange: function (e) { setParent(e.target.value); },
|
||||
className: "h-7 text-xs",
|
||||
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||
},
|
||||
h(SelectOption, { value: "" }, "— no parent —"),
|
||||
(props.allTasks || []).map(function (t) {
|
||||
@@ -1888,6 +1956,29 @@
|
||||
}).then(function () { load(); props.onRefresh(); });
|
||||
};
|
||||
|
||||
// Triage specifier — calls the auxiliary LLM to flesh out a rough
|
||||
// idea in the Triage column into a concrete spec (title + body with
|
||||
// goal, approach, acceptance criteria) and promotes it to todo.
|
||||
// Not a PATCH: runs through a dedicated POST endpoint because the
|
||||
// LLM call can take tens of seconds, and its outcome is richer than
|
||||
// a status flip (may update title AND body AND emit an audit
|
||||
// comment — or fail with a human-readable reason that the UI
|
||||
// surfaces inline without treating it as an HTTP error).
|
||||
const doSpecify = function () {
|
||||
return SDK.fetchJSON(
|
||||
withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/specify`, boardSlug),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
).then(function (res) {
|
||||
load();
|
||||
props.onRefresh();
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = function (parentId) {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
@@ -1977,6 +2068,7 @@
|
||||
assignees: props.assignees || [],
|
||||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onSpecify: doSpecify,
|
||||
onAddParent: addLink,
|
||||
onRemoveParent: removeLink,
|
||||
onAddChild: addChild,
|
||||
@@ -2045,7 +2137,11 @@
|
||||
}) : null,
|
||||
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
|
||||
),
|
||||
h(StatusActions, { task: t, onPatch: props.onPatch }),
|
||||
h(StatusActions, {
|
||||
task: t,
|
||||
onPatch: props.onPatch,
|
||||
onSpecify: props.onSpecify,
|
||||
}),
|
||||
h(DiagnosticsSection, {
|
||||
task: t,
|
||||
boardSlug: props.boardSlug,
|
||||
@@ -2478,6 +2574,8 @@
|
||||
|
||||
function StatusActions(props) {
|
||||
const t = props.task;
|
||||
const [specifyBusy, setSpecifyBusy] = useState(false);
|
||||
const [specifyMsg, setSpecifyMsg] = useState(null);
|
||||
const b = function (label, patch, enabled, confirmMsg) {
|
||||
return h(Button, {
|
||||
onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); },
|
||||
@@ -2485,22 +2583,67 @@
|
||||
size: "sm",
|
||||
}, label);
|
||||
};
|
||||
return h("div", { className: "hermes-kanban-actions" },
|
||||
b("→ triage", { status: "triage" }, t.status !== "triage"),
|
||||
b("→ ready", { status: "ready" }, t.status !== "ready"),
|
||||
// No direct → running button: /tasks/:id PATCH rejects status=running
|
||||
// with 400 (issue #19535). Tasks enter running only through the
|
||||
// dispatcher's claim_task path, which atomically creates the run row,
|
||||
// claim lock, and worker process metadata.
|
||||
b("Block", { status: "blocked" },
|
||||
t.status === "running" || t.status === "ready",
|
||||
DESTRUCTIVE_TRANSITIONS.blocked),
|
||||
b("Unblock", { status: "ready" }, t.status === "blocked"),
|
||||
b("Complete", { status: "done" },
|
||||
t.status === "running" || t.status === "ready" || t.status === "blocked",
|
||||
DESTRUCTIVE_TRANSITIONS.done),
|
||||
b("Archive", { status: "archived" }, t.status !== "archived",
|
||||
DESTRUCTIVE_TRANSITIONS.archived),
|
||||
|
||||
// "Specify" appears only when the task is in the Triage column — the
|
||||
// one column where an auxiliary LLM pass is meaningful. Elsewhere
|
||||
// the backend would return ok:false with "not in triage" anyway,
|
||||
// so hiding the button keeps the action row uncluttered.
|
||||
const specifyButton = (t.status === "triage" && props.onSpecify)
|
||||
? h(Button, {
|
||||
onClick: function () {
|
||||
if (specifyBusy) return;
|
||||
setSpecifyBusy(true);
|
||||
setSpecifyMsg(null);
|
||||
props.onSpecify().then(function (res) {
|
||||
if (res && res.ok) {
|
||||
const suffix = res.new_title
|
||||
? ` — retitled: ${res.new_title}`
|
||||
: "";
|
||||
setSpecifyMsg({ ok: true, text: `Specified${suffix}` });
|
||||
} else {
|
||||
setSpecifyMsg({
|
||||
ok: false,
|
||||
text: "Specify failed: " + ((res && res.reason) || "unknown error"),
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
setSpecifyMsg({
|
||||
ok: false,
|
||||
text: "Specify failed: " + (err.message || String(err)),
|
||||
});
|
||||
}).then(function () {
|
||||
setSpecifyBusy(false);
|
||||
});
|
||||
},
|
||||
disabled: specifyBusy,
|
||||
size: "sm",
|
||||
}, specifyBusy ? "Specifying…" : "✨ Specify")
|
||||
: null;
|
||||
|
||||
return h("div", null,
|
||||
h("div", { className: "hermes-kanban-actions" },
|
||||
specifyButton,
|
||||
b("→ triage", { status: "triage" }, t.status !== "triage"),
|
||||
b("→ ready", { status: "ready" }, t.status !== "ready"),
|
||||
// No direct → running button: /tasks/:id PATCH rejects status=running
|
||||
// with 400 (issue #19535). Tasks enter running only through the
|
||||
// dispatcher's claim_task path, which atomically creates the run row,
|
||||
// claim lock, and worker process metadata.
|
||||
b("Block", { status: "blocked" },
|
||||
t.status === "running" || t.status === "ready",
|
||||
DESTRUCTIVE_TRANSITIONS.blocked),
|
||||
b("Unblock", { status: "ready" }, t.status === "blocked"),
|
||||
b("Complete", { status: "done" },
|
||||
t.status === "running" || t.status === "ready" || t.status === "blocked",
|
||||
DESTRUCTIVE_TRANSITIONS.done),
|
||||
b("Archive", { status: "archived" }, t.status !== "archived",
|
||||
DESTRUCTIVE_TRANSITIONS.archived),
|
||||
),
|
||||
specifyMsg ? h("div", {
|
||||
className: specifyMsg.ok
|
||||
? "hermes-kanban-msg-ok"
|
||||
: "hermes-kanban-msg-err",
|
||||
}, specifyMsg.text) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+104
-9
@@ -9,14 +9,56 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Override the Nous DS global `code { background: var(--midground) }` rule
|
||||
which paints an opaque cream/yellow fill on every <code> inside the board,
|
||||
hiding the text underneath. Kanban uses <code> for event payloads, run-meta,
|
||||
and log panes — those need transparent backgrounds. */
|
||||
.hermes-kanban code {
|
||||
background: transparent;
|
||||
/* ---- Code/pre reset (theme-immune default) --------------------------- *
|
||||
*
|
||||
* Themes (shipped AND user-installable) routinely paint every <code> and
|
||||
* <pre> on the page with an opaque accent-color fill. That's fine for a
|
||||
* Markdown doc page; it's wrong for the kanban plugin, which uses <code>
|
||||
* for event payloads, run metadata, log panes, and similar raw-data
|
||||
* surfaces that must read as plain text on the board's own background.
|
||||
*
|
||||
* Rather than play whack-a-mole with theme rules (the pre-#21086 approach
|
||||
* was a single ``.hermes-kanban code { background: transparent }`` rule
|
||||
* that lost specificity fights in the drawer context), reset EVERY
|
||||
* <code>/<pre> inside the kanban plugin container to transparent with
|
||||
* ``!important``, then opt back in ONLY on the class that carries
|
||||
* intentional styling (``.hermes-kanban-md code``, the inline code pill
|
||||
* inside rendered task-body Markdown).
|
||||
*
|
||||
* Net effect: any new theme, shipped or third-party, can introduce
|
||||
* whatever global code-fill rule it wants — kanban surfaces stay clean
|
||||
* unless the theme deliberately targets our internal class names.
|
||||
* Regression coverage: #21086 (task-drawer event payloads unreadable
|
||||
* across every shipped theme).
|
||||
*/
|
||||
.hermes-kanban code,
|
||||
.hermes-kanban pre,
|
||||
.hermes-kanban-drawer code,
|
||||
.hermes-kanban-drawer pre {
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
/* The Markdown renderer intentionally paints a subtle code pill behind
|
||||
* inline ``<code>`` inside task-body prose — but NOT inside a fenced
|
||||
* block (those are a ``<pre class="hermes-kanban-md-code">`` with a
|
||||
* bare ``<code>`` inside, and the pill would double up with the pre
|
||||
* background). ``:not()`` scopes this opt-back-in to inline code only.
|
||||
*
|
||||
* Uses ``color-mix(currentColor ...)`` rather than ``--color-foreground``
|
||||
* so the pill renders consistently even when a theme forgets to set
|
||||
* ``--color-foreground`` (pre-existing safeguard from #18576).
|
||||
*/
|
||||
.hermes-kanban .hermes-kanban-md code:not(.hermes-kanban-md-code *) {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent) !important;
|
||||
}
|
||||
/* Tighten contrast on the drawer-specific payload class — it lives on
|
||||
* its own line in the events list, so matching the muted-foreground
|
||||
* color keeps it visually distinct from the event title without
|
||||
* screaming for attention. */
|
||||
.hermes-kanban-event-payload,
|
||||
.hermes-kanban-drawer .hermes-kanban-event-payload {
|
||||
color: var(--color-muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* ---- Columns layout -------------------------------------------------- */
|
||||
|
||||
@@ -360,6 +402,26 @@
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* Specifier result banner — sits directly under the status action row. */
|
||||
.hermes-kanban-msg-ok,
|
||||
.hermes-kanban-msg-err {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.hermes-kanban-msg-ok {
|
||||
background: rgba(46, 160, 67, 0.12);
|
||||
color: #2ea043;
|
||||
border: 1px solid rgba(46, 160, 67, 0.35);
|
||||
}
|
||||
.hermes-kanban-msg-err {
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
color: #f85149;
|
||||
border: 1px solid rgba(248, 81, 73, 0.35);
|
||||
}
|
||||
|
||||
/* ---- Home channel subscription toggles (per-platform, per-task) ----- */
|
||||
|
||||
.hermes-kanban-home-subs {
|
||||
@@ -668,7 +730,9 @@
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
|
||||
/* Background is set in the code/pre reset block at the top of this
|
||||
* file with !important, so theme-level global code rules can't knock
|
||||
* out this intentional pill. See #21086. */
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -678,10 +742,15 @@
|
||||
* UA default on <code> elements — otherwise themes that don't set
|
||||
* --color-foreground leave code text rendering near-black on dark themes
|
||||
* (see issue #18576). */
|
||||
.hermes-kanban-md-code {
|
||||
.hermes-kanban pre.hermes-kanban-md-code {
|
||||
margin: 0.35rem 0;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
/* Higher specificity (``.hermes-kanban pre.hermes-kanban-md-code`` vs
|
||||
* the reset's ``.hermes-kanban pre``) so this intentional pill wins
|
||||
* over our own ``<pre>`` reset. ``!important`` also needed so theme
|
||||
* rules that drop their own ``code``/``pre`` fill don't knock it out
|
||||
* either. #21086. */
|
||||
background: color-mix(in srgb, currentColor 6%, transparent) !important;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
overflow-x: auto;
|
||||
@@ -822,6 +891,32 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0.25rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.hermes-kanban-docs-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--color-muted-foreground, rgba(180, 180, 200, 0.8));
|
||||
background: var(--color-card-subtle, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
|
||||
text-decoration: none;
|
||||
cursor: help;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.hermes-kanban-docs-link:hover,
|
||||
.hermes-kanban-docs-link:focus-visible {
|
||||
color: var(--color-foreground, #e7e7ee);
|
||||
background: var(--color-card, rgba(255, 255, 255, 0.08));
|
||||
border-color: var(--color-border, rgba(160, 160, 190, 0.45));
|
||||
outline: none;
|
||||
}
|
||||
.hermes-kanban-dialog-backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -30,6 +30,7 @@ import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
@@ -1011,6 +1012,61 @@ def reclaim_task_endpoint(
|
||||
conn.close()
|
||||
|
||||
|
||||
class SpecifyBody(BaseModel):
|
||||
"""Optional author override. Nothing else is configurable from the
|
||||
dashboard — model + prompt come from ``auxiliary.triage_specifier``
|
||||
in config.yaml, same as the CLI."""
|
||||
|
||||
author: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/specify")
|
||||
def specify_task_endpoint(
|
||||
task_id: str,
|
||||
payload: SpecifyBody,
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Flesh out a triage-column task via the auxiliary LLM and promote
|
||||
it to ``todo``. Maps 1:1 to ``hermes kanban specify <task_id>``.
|
||||
|
||||
Returns the outcome shape used by the CLI: ``{ok, task_id, reason,
|
||||
new_title}``. A non-OK outcome is NOT an HTTP error — the UI renders
|
||||
the reason inline (e.g. "no auxiliary client configured") so the
|
||||
operator knows what to fix, and retries without a page reload.
|
||||
|
||||
This endpoint runs in FastAPI's threadpool (sync ``def``) because
|
||||
the underlying LLM call can take tens of seconds to minutes on
|
||||
reasoning models, which would block the event loop if we used
|
||||
``async def`` without an explicit ``run_in_executor``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
# Pin the board for the duration of this call so the specifier module
|
||||
# (which calls ``kb.connect()`` with no args) hits the right DB.
|
||||
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
try:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = board or kanban_db.DEFAULT_BOARD
|
||||
# Import lazily so a missing auxiliary client at import time
|
||||
# doesn't break plugin load.
|
||||
from hermes_cli import kanban_specify # noqa: WPS433 (intentional)
|
||||
|
||||
outcome = kanban_specify.specify_task(
|
||||
task_id,
|
||||
author=(payload.author or None),
|
||||
)
|
||||
finally:
|
||||
if prev_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_env
|
||||
|
||||
return {
|
||||
"ok": bool(outcome.ok),
|
||||
"task_id": outcome.task_id,
|
||||
"reason": outcome.reason,
|
||||
"new_title": outcome.new_title,
|
||||
}
|
||||
|
||||
|
||||
class ReassignBody(BaseModel):
|
||||
profile: Optional[str] = None # "" or None = unassign
|
||||
reclaim_first: bool = False
|
||||
@@ -1521,6 +1577,13 @@ async def stream_events(ws: WebSocket):
|
||||
await asyncio.sleep(_EVENT_POLL_SECONDS)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
# Normal shutdown path: dashboard process exit (Ctrl-C) cancels the
|
||||
# websocket task while it is sleeping in the poll loop.
|
||||
# CancelledError is a BaseException in 3.8+ so the bare Exception
|
||||
# handler below would not catch it; without this clause Uvicorn
|
||||
# surfaces the cancellation as an application traceback. Quiet it.
|
||||
return
|
||||
except Exception as exc: # defensive: never crash the dashboard worker
|
||||
log.warning("Kanban event stream error: %s", exc)
|
||||
try:
|
||||
|
||||
@@ -27,9 +27,16 @@ from __future__ import annotations
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
@@ -38,6 +45,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
|
||||
_TIMEOUT = 30.0
|
||||
_REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -92,38 +100,94 @@ class _VikingClient:
|
||||
raise ImportError("httpx is required for OpenViking: pip install httpx")
|
||||
|
||||
def _headers(self) -> dict:
|
||||
# Only send tenant headers when the user actually configured them.
|
||||
# Legacy installs had account/user defaulted to the literal string
|
||||
# "default" — treat that as unset so authenticated remote servers
|
||||
# that derive tenancy from the Bearer key aren't overridden by a
|
||||
# bogus tenant value.
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"X-OpenViking-Account": self._account,
|
||||
"X-OpenViking-User": self._user,
|
||||
"X-OpenViking-Agent": self._agent,
|
||||
}
|
||||
if self._account and self._account != "default":
|
||||
h["X-OpenViking-Account"] = self._account
|
||||
if self._user and self._user != "default":
|
||||
h["X-OpenViking-User"] = self._user
|
||||
if self._api_key:
|
||||
h["X-API-Key"] = self._api_key
|
||||
h["Authorization"] = "Bearer " + self._api_key
|
||||
return h
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._endpoint}{path}"
|
||||
|
||||
def _multipart_headers(self) -> dict:
|
||||
headers = self._headers()
|
||||
headers.pop("Content-Type", None)
|
||||
return headers
|
||||
|
||||
def _parse_response(self, resp) -> dict:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = None
|
||||
|
||||
if resp.status_code >= 400:
|
||||
if isinstance(data, dict):
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = error.get("code", "HTTP_ERROR")
|
||||
message = error.get("message", resp.text)
|
||||
raise RuntimeError(f"{code}: {message}")
|
||||
if data.get("status") == "error":
|
||||
raise RuntimeError(str(data))
|
||||
resp.raise_for_status()
|
||||
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = error.get("code", "OPENVIKING_ERROR")
|
||||
message = error.get("message", "")
|
||||
raise RuntimeError(f"{code}: {message}")
|
||||
raise RuntimeError(str(data))
|
||||
|
||||
if data is None:
|
||||
return {}
|
||||
return data
|
||||
|
||||
def get(self, path: str, **kwargs) -> dict:
|
||||
resp = self._httpx.get(
|
||||
self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._parse_response(resp)
|
||||
|
||||
def post(self, path: str, payload: dict = None, **kwargs) -> dict:
|
||||
resp = self._httpx.post(
|
||||
self._url(path), json=payload or {}, headers=self._headers(),
|
||||
timeout=_TIMEOUT, **kwargs
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._parse_response(resp)
|
||||
|
||||
def upload_temp_file(self, file_path: Path) -> str:
|
||||
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
||||
with file_path.open("rb") as f:
|
||||
resp = self._httpx.post(
|
||||
self._url("/api/v1/resources/temp_upload"),
|
||||
files={"file": (file_path.name, f, mime_type)},
|
||||
headers=self._multipart_headers(),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
data = self._parse_response(resp)
|
||||
result = data.get("result", {})
|
||||
temp_file_id = result.get("temp_file_id", "")
|
||||
if not temp_file_id:
|
||||
raise RuntimeError("OpenViking temp upload did not return temp_file_id")
|
||||
return temp_file_id
|
||||
|
||||
def health(self) -> bool:
|
||||
try:
|
||||
resp = self._httpx.get(
|
||||
self._url("/health"), timeout=3.0
|
||||
self._url("/health"), headers=self._headers(), timeout=3.0
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
@@ -230,24 +294,90 @@ REMEMBER_SCHEMA = {
|
||||
ADD_RESOURCE_SCHEMA = {
|
||||
"name": "viking_add_resource",
|
||||
"description": (
|
||||
"Add a URL or document to the OpenViking knowledge base. "
|
||||
"Supports web pages, GitHub repos, PDFs, markdown, code files. "
|
||||
"Add a remote URL or local file/directory to the OpenViking knowledge base. "
|
||||
"Remote resources must be public http(s), git, or ssh URLs. "
|
||||
"Local files are uploaded first using OpenViking temp_upload. "
|
||||
"The system automatically parses, indexes, and generates summaries."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL or path of the resource to add."},
|
||||
"url": {"type": "string", "description": "Remote URL or local file/directory path to add."},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this resource is relevant (improves search).",
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Optional target viking:// URI for the resource.",
|
||||
},
|
||||
"parent": {
|
||||
"type": "string",
|
||||
"description": "Optional parent viking:// URI. Cannot be used with to.",
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "Optional processing instruction for semantic extraction.",
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to wait for processing to complete.",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "Timeout in seconds when wait is true.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _zip_directory(dir_path: Path) -> Path:
|
||||
"""Create a temporary zip file containing a directory tree."""
|
||||
zip_path = Path(tempfile.gettempdir()) / f"openviking_upload_{uuid.uuid4().hex}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path in dir_path.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = str(file_path.relative_to(dir_path)).replace("\\", "/")
|
||||
zipf.write(file_path, arcname=arcname)
|
||||
return zip_path
|
||||
|
||||
|
||||
def _is_windows_absolute_path(value: str) -> bool:
|
||||
return (
|
||||
len(value) >= 3
|
||||
and value[0].isalpha()
|
||||
and value[1] == ":"
|
||||
and value[2] in ("/", "\\")
|
||||
)
|
||||
|
||||
|
||||
def _is_remote_resource_source(value: str) -> bool:
|
||||
return value.startswith(_REMOTE_RESOURCE_PREFIXES)
|
||||
|
||||
|
||||
def _is_local_path_reference(value: str) -> bool:
|
||||
if not value or "\n" in value or "\r" in value:
|
||||
return False
|
||||
if _is_remote_resource_source(value):
|
||||
return False
|
||||
if _is_windows_absolute_path(value):
|
||||
return True
|
||||
return (
|
||||
value.startswith(("/", "./", "../", "~/", ".\\", "..\\", "~\\"))
|
||||
or "/" in value
|
||||
or "\\" in value
|
||||
)
|
||||
|
||||
|
||||
def _path_from_file_uri(uri: str) -> Path | str:
|
||||
parsed = urlparse(uri)
|
||||
if parsed.netloc not in ("", "localhost"):
|
||||
return f"Unsupported non-local file URI: {uri}"
|
||||
return Path(url2pathname(parsed.path)).expanduser()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -744,12 +874,52 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
if not url:
|
||||
return tool_error("url is required")
|
||||
|
||||
payload: Dict[str, Any] = {"path": url}
|
||||
if args.get("reason"):
|
||||
payload["reason"] = args["reason"]
|
||||
if args.get("to") and args.get("parent"):
|
||||
return tool_error("Cannot specify both 'to' and 'parent'")
|
||||
|
||||
resp = self._client.post("/api/v1/resources", payload)
|
||||
result = resp.get("result", {})
|
||||
payload: Dict[str, Any] = {}
|
||||
for key in ("reason", "to", "parent", "instruction", "wait", "timeout"):
|
||||
if key in args and args[key] not in (None, ""):
|
||||
payload[key] = args[key]
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
if _is_remote_resource_source(url):
|
||||
source_path = None
|
||||
elif parsed_url.scheme == "file":
|
||||
source_path = _path_from_file_uri(url)
|
||||
if isinstance(source_path, str):
|
||||
return tool_error(source_path)
|
||||
elif parsed_url.scheme and not _is_windows_absolute_path(url):
|
||||
source_path = None
|
||||
else:
|
||||
source_path = Path(url).expanduser()
|
||||
|
||||
cleanup_path: Optional[Path] = None
|
||||
try:
|
||||
if source_path is not None:
|
||||
if source_path.exists():
|
||||
if source_path.is_dir():
|
||||
payload["source_name"] = source_path.name
|
||||
cleanup_path = _zip_directory(source_path)
|
||||
upload_path = cleanup_path
|
||||
elif source_path.is_file():
|
||||
payload["source_name"] = source_path.name
|
||||
upload_path = source_path
|
||||
else:
|
||||
return tool_error(f"Unsupported local resource path: {url}")
|
||||
payload["temp_file_id"] = self._client.upload_temp_file(upload_path)
|
||||
elif _is_local_path_reference(url):
|
||||
return tool_error(f"Local resource path does not exist: {url}")
|
||||
else:
|
||||
payload["path"] = url
|
||||
else:
|
||||
payload["path"] = url
|
||||
|
||||
resp = self._client.post("/api/v1/resources", payload)
|
||||
result = resp.get("result", {})
|
||||
finally:
|
||||
if cleanup_path:
|
||||
cleanup_path.unlink(missing_ok=True)
|
||||
|
||||
return json.dumps({
|
||||
"status": "added",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,638 @@
|
||||
"""User OAuth helper for the Google Chat gateway adapter.
|
||||
|
||||
Google Chat's ``media.upload`` REST endpoint hard-rejects service-account
|
||||
authentication:
|
||||
|
||||
"This method doesn't support app authentication with a service
|
||||
account. Authenticate with a user account."
|
||||
|
||||
(See https://developers.google.com/workspace/chat/api/reference/rest/v1/media/upload
|
||||
and https://developers.google.com/chat/api/guides/auth/users.)
|
||||
|
||||
For the bot to deliver native file attachments — the same drag-and-drop
|
||||
file widget the user gets when they upload manually — each user must
|
||||
grant the bot the ``chat.messages.create`` scope ONCE in their own DM.
|
||||
The bot stores per-user refresh tokens and calls ``media.upload`` plus
|
||||
the subsequent ``messages.create`` *as the requesting user* whenever a
|
||||
file needs sending.
|
||||
|
||||
This module is BOTH a CLI tool (driven by the agent via slash commands or
|
||||
terminal commands) AND a library imported by ``google_chat.py``:
|
||||
|
||||
Library functions (called from the adapter at runtime):
|
||||
load_user_credentials(email=None) -> Credentials | None
|
||||
refresh_or_none(creds, email=None) -> Credentials | None
|
||||
build_user_chat_service(creds) -> chat_v1.Resource
|
||||
list_authorized_emails() -> List[str]
|
||||
|
||||
CLI commands (driven by the agent through the /setup-files slash
|
||||
command, modeled on skills/productivity/google-workspace/scripts/setup.py):
|
||||
--check Exit 0 if auth is valid, else 1
|
||||
--client-secret /path/to.json Persist OAuth client credentials
|
||||
--auth-url Print the OAuth URL for the user
|
||||
--auth-code CODE Exchange auth code for token
|
||||
--revoke Revoke and delete stored token
|
||||
--install-deps Install Python dependencies
|
||||
--email EMAIL Scope CLI ops to a specific user
|
||||
(defaults to legacy single-user
|
||||
mode when omitted)
|
||||
|
||||
The flow mirrors the existing google-workspace skill exactly so anyone
|
||||
familiar with that flow can read this without surprises.
|
||||
|
||||
Token storage layout
|
||||
--------------------
|
||||
- Per-user tokens (keyed by sender email):
|
||||
``${HERMES_HOME}/google_chat_user_tokens/<sanitized_email>.json``
|
||||
- Legacy single-user token (fallback, untouched for backward compat):
|
||||
``${HERMES_HOME}/google_chat_user_token.json``
|
||||
- Per-user pending OAuth state during /setup-files start → exchange:
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
|
||||
- Legacy pending state:
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending.json``
|
||||
- Shared OAuth client (one per host):
|
||||
``${HERMES_HOME}/google_chat_user_client_secret.json``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
# Pin the legacy logger name so operator-side log filters keep matching
|
||||
# after the in-tree → plugin migration. See adapter.py for context.
|
||||
logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
|
||||
|
||||
# Use the project's HERMES_HOME helper so the token follows the user's
|
||||
# profile (e.g. tests can override via HERMES_HOME=/tmp/...).
|
||||
try:
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# Fallback for environments where hermes_constants isn't importable
|
||||
# (mirrors the same fallback used by the google-workspace skill's
|
||||
# _hermes_home.py shim).
|
||||
def get_hermes_home() -> Path:
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
def display_hermes_home() -> str:
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
return "~/" + str(home.relative_to(Path.home()))
|
||||
except ValueError:
|
||||
return str(home)
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
"""Resolve HERMES_HOME at call time (NOT module import).
|
||||
|
||||
Tests and ``HERMES_HOME=...`` env overrides need this to be late-
|
||||
binding. If we cached the path at import time, switching profiles
|
||||
or tweaking env vars in tests would silently keep using the old
|
||||
path."""
|
||||
return get_hermes_home()
|
||||
|
||||
|
||||
# Filesystem-safe key: lowercase, allow ``[a-z0-9._-@]``, replace anything
|
||||
# else with ``_``. ``ramon.fernandez@nttdata.com`` stays human-readable
|
||||
# (``ramon.fernandez@nttdata.com.json``) which makes admin debugging by
|
||||
# ``ls ~/.hermes/google_chat_user_tokens/`` trivial.
|
||||
_EMAIL_FS_RE = re.compile(r"[^a-z0-9._@-]+")
|
||||
|
||||
|
||||
def _sanitize_email(email: str) -> str:
|
||||
cleaned = _EMAIL_FS_RE.sub("_", (email or "").strip().lower())
|
||||
return cleaned or "_unknown_"
|
||||
|
||||
|
||||
def _legacy_token_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_token.json"
|
||||
|
||||
|
||||
def _user_tokens_dir() -> Path:
|
||||
return _hermes_home() / "google_chat_user_tokens"
|
||||
|
||||
|
||||
def _legacy_pending_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_oauth_pending.json"
|
||||
|
||||
|
||||
def _user_pending_dir() -> Path:
|
||||
return _hermes_home() / "google_chat_user_oauth_pending"
|
||||
|
||||
|
||||
def _token_path(email: Optional[str] = None) -> Path:
|
||||
"""Return the on-disk token path for ``email`` or the legacy path."""
|
||||
if email:
|
||||
return _user_tokens_dir() / f"{_sanitize_email(email)}.json"
|
||||
return _legacy_token_path()
|
||||
|
||||
|
||||
def _client_secret_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_client_secret.json"
|
||||
|
||||
|
||||
def _pending_auth_path(email: Optional[str] = None) -> Path:
|
||||
if email:
|
||||
return _user_pending_dir() / f"{_sanitize_email(email)}.json"
|
||||
return _legacy_pending_path()
|
||||
|
||||
|
||||
# Minimum scope for native Chat attachment delivery.
|
||||
# `chat.messages.create` covers BOTH `media.upload` and the subsequent
|
||||
# `messages.create` that references the attachmentDataRef. We deliberately
|
||||
# do NOT request drive.file or other scopes — least privilege.
|
||||
SCOPES: List[str] = [
|
||||
"https://www.googleapis.com/auth/chat.messages.create",
|
||||
]
|
||||
|
||||
# Pip packages required for the OAuth flow.
|
||||
_REQUIRED_PACKAGES = [
|
||||
"google-api-python-client",
|
||||
"google-auth-oauthlib",
|
||||
"google-auth-httplib2",
|
||||
]
|
||||
|
||||
# Out-of-band redirect: Google deprecated the ``urn:ietf:wg:oauth:2.0:oob``
|
||||
# flow, so we use a localhost redirect that's expected to FAIL. The user
|
||||
# copies the auth code from the failed browser URL bar back into chat.
|
||||
# Same trick used by skills/productivity/google-workspace/scripts/setup.py.
|
||||
_REDIRECT_URI = "http://localhost:1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Library API — called from the adapter at runtime
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
|
||||
"""Load + validate persisted user OAuth credentials.
|
||||
|
||||
``email`` selects the per-user token file; ``None`` falls back to the
|
||||
legacy single-user path (left in place for installs that ran the
|
||||
pre-multi-user flow). Returns a ``google.oauth2.credentials.Credentials``
|
||||
instance ready for use, or ``None`` if no token is stored, the token
|
||||
is corrupt, or refresh fails. Adapter callers should treat ``None``
|
||||
as "user has not run /setup-files yet" and surface the setup-instructions
|
||||
fallback to the user.
|
||||
|
||||
Does NOT raise on the no-token case — that's expected.
|
||||
"""
|
||||
token_path = _token_path(email)
|
||||
if not token_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
|
||||
"attachment delivery is disabled. Install hermes-agent[google_chat]."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Don't pass scopes — user may have authorized only a subset, and
|
||||
# passing scopes makes refresh validate them strictly. Same logic
|
||||
# as the google-workspace skill.
|
||||
creds = Credentials.from_authorized_user_file(str(token_path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] token at %s is corrupt: %s",
|
||||
token_path, exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if creds.valid:
|
||||
return creds
|
||||
|
||||
if creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] token refresh failed (user "
|
||||
"should re-run /setup-files): %s", exc,
|
||||
)
|
||||
return None
|
||||
# Persist refreshed token so next start picks up the new access
|
||||
# token without an unnecessary refresh round-trip.
|
||||
_persist_credentials(creds, token_path)
|
||||
return creds
|
||||
|
||||
# Token exists but is unusable (e.g. revoked, no refresh token).
|
||||
return None
|
||||
|
||||
|
||||
def refresh_or_none(creds: Any, email: Optional[str] = None) -> Optional[Any]:
|
||||
"""Refresh ``creds`` if expired. Returns the credentials or ``None``.
|
||||
|
||||
Used by the adapter just before calling media.upload to ensure the
|
||||
token is current. Returns ``None`` if refresh fails — caller falls
|
||||
back to the text-notice path. ``email`` controls where the refreshed
|
||||
token is written back; ``None`` keeps the legacy single-file path.
|
||||
"""
|
||||
if creds is None:
|
||||
return None
|
||||
|
||||
if creds.valid:
|
||||
return creds
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
_persist_credentials(creds, _token_path(email))
|
||||
return creds
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] refresh failed: %s", exc,
|
||||
)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_user_chat_service(creds: Any) -> Any:
|
||||
"""Build a Google Chat API client authenticated as the user.
|
||||
|
||||
Used for media.upload + the subsequent messages.create that
|
||||
references the attachmentDataRef. The bot's separate SA-authed
|
||||
client (``self._chat_api`` in the adapter) is for everything else.
|
||||
"""
|
||||
from googleapiclient.discovery import build as build_service
|
||||
return build_service("chat", "v1", credentials=creds, cache_discovery=False)
|
||||
|
||||
|
||||
def list_authorized_emails() -> List[str]:
|
||||
"""Return the set of user emails that have stored per-user tokens.
|
||||
|
||||
Lists files in the per-user tokens dir; does NOT include the legacy
|
||||
single-user token (its owner is unknown). Sanitized filenames lose
|
||||
the ``+suffix`` part of plus-addressed emails — accept that and use
|
||||
this list only for admin display, not for trust decisions.
|
||||
"""
|
||||
d = _user_tokens_dir()
|
||||
if not d.exists():
|
||||
return []
|
||||
out: List[str] = []
|
||||
for f in d.iterdir():
|
||||
if f.is_file() and f.suffix == ".json":
|
||||
out.append(f.stem)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def _persist_credentials(creds: Any, token_path: Path) -> None:
|
||||
"""Atomic-ish JSON write of refreshed credentials."""
|
||||
try:
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[google_chat_user_oauth] failed to persist credentials at %s",
|
||||
token_path, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI commands — driven by the agent via /setup-files
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _normalize_authorized_user_payload(payload: dict) -> dict:
|
||||
"""Ensure the persisted token JSON has the type field google-auth expects."""
|
||||
normalized = dict(payload)
|
||||
if not normalized.get("type"):
|
||||
normalized["type"] = "authorized_user"
|
||||
return normalized
|
||||
|
||||
|
||||
def _ensure_deps() -> None:
|
||||
"""Check deps available; install if not; exit on failure."""
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
except ImportError:
|
||||
if not install_deps():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def install_deps() -> bool:
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
print("Dependencies already installed.")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print("Installing Google Chat OAuth dependencies...")
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"ERROR: Failed to install dependencies: {exc}")
|
||||
print("Or install via the optional extra:")
|
||||
print(" pip install 'hermes-agent[google_chat]'")
|
||||
return False
|
||||
|
||||
|
||||
def check_auth(email: Optional[str] = None) -> bool:
|
||||
"""Print status; return True if creds are usable.
|
||||
|
||||
Per-user when ``email`` given, legacy single-user when omitted.
|
||||
"""
|
||||
token_path = _token_path(email)
|
||||
if not token_path.exists():
|
||||
print(f"NOT_AUTHENTICATED: No token at {token_path}")
|
||||
return False
|
||||
|
||||
creds = load_user_credentials(email)
|
||||
if creds is None:
|
||||
print(f"TOKEN_INVALID: Re-run /setup-files (path: {token_path})")
|
||||
return False
|
||||
|
||||
print(f"AUTHENTICATED: Token valid at {token_path}")
|
||||
return True
|
||||
|
||||
|
||||
def store_client_secret(path: str) -> None:
|
||||
"""Validate and copy the user's OAuth client_secret.json into HERMES_HOME."""
|
||||
src = Path(path).expanduser().resolve()
|
||||
if not src.exists():
|
||||
print(f"ERROR: File not found: {src}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(src.read_text())
|
||||
except json.JSONDecodeError:
|
||||
print("ERROR: File is not valid JSON.")
|
||||
sys.exit(1)
|
||||
|
||||
if "installed" not in data and "web" not in data:
|
||||
print(
|
||||
"ERROR: Not a Google OAuth client secret file (missing "
|
||||
"'installed' or 'web' key)."
|
||||
)
|
||||
print(
|
||||
"Download from: https://console.cloud.google.com/apis/credentials"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
target = _client_secret_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(data, indent=2))
|
||||
print(f"OK: Client secret saved to {target}")
|
||||
|
||||
|
||||
def _save_pending_auth(*, state: str, code_verifier: str,
|
||||
email: Optional[str] = None) -> None:
|
||||
pending = _pending_auth_path(email)
|
||||
pending.parent.mkdir(parents=True, exist_ok=True)
|
||||
pending.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _load_pending_auth(email: Optional[str] = None) -> dict:
|
||||
pending = _pending_auth_path(email)
|
||||
if not pending.exists():
|
||||
print("ERROR: No pending OAuth session found. Run --auth-url first.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(pending.read_text())
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Could not read pending OAuth session: {exc}")
|
||||
print("Run --auth-url again to start a fresh session.")
|
||||
sys.exit(1)
|
||||
if not data.get("state") or not data.get("code_verifier"):
|
||||
print("ERROR: Pending OAuth session is missing PKCE data.")
|
||||
print("Run --auth-url again.")
|
||||
sys.exit(1)
|
||||
return data
|
||||
|
||||
|
||||
def _extract_code_and_state(code_or_url: str) -> Tuple[str, Optional[str]]:
|
||||
"""Accept a raw auth code OR the full failed-redirect URL the user pastes."""
|
||||
if not code_or_url.startswith("http"):
|
||||
return code_or_url, None
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(code_or_url)
|
||||
params = parse_qs(parsed.query)
|
||||
if "code" not in params:
|
||||
print("ERROR: No 'code' parameter found in URL.")
|
||||
sys.exit(1)
|
||||
state = params.get("state", [None])[0]
|
||||
return params["code"][0], state
|
||||
|
||||
|
||||
def get_auth_url(email: Optional[str] = None) -> None:
|
||||
"""Print the OAuth URL for the user to visit. Persists PKCE state.
|
||||
|
||||
``email`` namespaces the pending state so two users can be mid-flow
|
||||
in parallel without trampling each other's PKCE verifier.
|
||||
"""
|
||||
if not _client_secret_path().exists():
|
||||
print("ERROR: No client secret stored. Run --client-secret first.")
|
||||
sys.exit(1)
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(_client_secret_path()),
|
||||
scopes=SCOPES,
|
||||
redirect_uri=_REDIRECT_URI,
|
||||
autogenerate_code_verifier=True,
|
||||
)
|
||||
auth_url, state = flow.authorization_url(
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
)
|
||||
_save_pending_auth(state=state, code_verifier=flow.code_verifier, email=email)
|
||||
print(auth_url)
|
||||
|
||||
|
||||
def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
|
||||
"""Exchange an auth code (or pasted redirect URL) for a refresh token.
|
||||
|
||||
``email`` selects the destination token path. ``None`` writes to the
|
||||
legacy single-user path (kept for the existing CLI entrypoint and for
|
||||
pre-multi-user installs).
|
||||
"""
|
||||
if not _client_secret_path().exists():
|
||||
print("ERROR: No client secret stored. Run --client-secret first.")
|
||||
sys.exit(1)
|
||||
|
||||
pending_auth = _load_pending_auth(email)
|
||||
raw_callback = code
|
||||
code, returned_state = _extract_code_and_state(code)
|
||||
if returned_state and returned_state != pending_auth["state"]:
|
||||
print(
|
||||
"ERROR: OAuth state mismatch. Run --auth-url again to start a "
|
||||
"fresh session."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
granted_scopes = list(SCOPES)
|
||||
if isinstance(raw_callback, str) and raw_callback.startswith("http"):
|
||||
params = parse_qs(urlparse(raw_callback).query)
|
||||
scope_val = (params.get("scope") or [""])[0].strip()
|
||||
if scope_val:
|
||||
granted_scopes = scope_val.split()
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(_client_secret_path()),
|
||||
scopes=granted_scopes,
|
||||
redirect_uri=pending_auth.get("redirect_uri", _REDIRECT_URI),
|
||||
state=pending_auth["state"],
|
||||
code_verifier=pending_auth["code_verifier"],
|
||||
)
|
||||
|
||||
try:
|
||||
# Accept partial scopes — user may deselect items in the consent screen.
|
||||
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Token exchange failed: {exc}")
|
||||
print("The code may have expired. Run --auth-url to get a fresh URL.")
|
||||
sys.exit(1)
|
||||
|
||||
creds = flow.credentials
|
||||
token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json()))
|
||||
|
||||
actually_granted = (
|
||||
list(creds.granted_scopes or [])
|
||||
if hasattr(creds, "granted_scopes") and creds.granted_scopes
|
||||
else []
|
||||
)
|
||||
if actually_granted:
|
||||
token_payload["scopes"] = actually_granted
|
||||
elif granted_scopes != SCOPES:
|
||||
token_payload["scopes"] = granted_scopes
|
||||
|
||||
token_path = _token_path(email)
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(json.dumps(token_payload, indent=2))
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
|
||||
print(f"OK: Authenticated. Token saved to {token_path}")
|
||||
rel_label = (
|
||||
f"{display_hermes_home()}/google_chat_user_tokens/{_sanitize_email(email)}.json"
|
||||
if email
|
||||
else f"{display_hermes_home()}/google_chat_user_token.json"
|
||||
)
|
||||
print(f"Profile path: {rel_label}")
|
||||
|
||||
|
||||
def revoke(email: Optional[str] = None) -> None:
|
||||
"""Revoke the stored token with Google and delete it locally.
|
||||
|
||||
Per-user when ``email`` given, legacy single-user when omitted.
|
||||
"""
|
||||
token_path = _token_path(email)
|
||||
if not token_path.exists():
|
||||
print("No token to revoke.")
|
||||
return
|
||||
|
||||
_ensure_deps()
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
try:
|
||||
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
|
||||
import urllib.request
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
f"https://oauth2.googleapis.com/revoke?token={creds.token}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
)
|
||||
print("Token revoked with Google.")
|
||||
except Exception as exc:
|
||||
print(f"Remote revocation failed (token may already be invalid): {exc}")
|
||||
|
||||
token_path.unlink(missing_ok=True)
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
print(f"Deleted {token_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Google Chat user-OAuth setup for Hermes (native attachment delivery)"
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true",
|
||||
help="Check if auth is valid (exit 0=yes, 1=no)")
|
||||
group.add_argument("--client-secret", metavar="PATH",
|
||||
help="Store OAuth client_secret.json")
|
||||
group.add_argument("--auth-url", action="store_true",
|
||||
help="Print OAuth URL for user to visit")
|
||||
group.add_argument("--auth-code", metavar="CODE",
|
||||
help="Exchange auth code for token")
|
||||
group.add_argument("--revoke", action="store_true",
|
||||
help="Revoke and delete stored token")
|
||||
group.add_argument("--install-deps", action="store_true",
|
||||
help="Install Python dependencies")
|
||||
parser.add_argument("--email", metavar="EMAIL", default=None,
|
||||
help="Scope operation to a specific user's token "
|
||||
"(default: legacy single-user path)")
|
||||
args = parser.parse_args()
|
||||
|
||||
email = args.email or None
|
||||
if args.check:
|
||||
sys.exit(0 if check_auth(email) else 1)
|
||||
elif args.client_secret:
|
||||
store_client_secret(args.client_secret)
|
||||
elif args.auth_url:
|
||||
get_auth_url(email)
|
||||
elif args.auth_code:
|
||||
exchange_auth_code(args.auth_code, email)
|
||||
elif args.revoke:
|
||||
revoke(email)
|
||||
elif args.install_deps:
|
||||
sys.exit(0 if install_deps() else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
name: google_chat-platform
|
||||
label: Google Chat
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Google Chat gateway adapter for Hermes Agent.
|
||||
Connects via Cloud Pub/Sub pull subscription for inbound events and the
|
||||
Google Chat REST API for outbound messages — same ergonomics as Slack
|
||||
Socket Mode or Telegram long-polling, no public URL required. Native
|
||||
file attachments are delivered via per-user OAuth (each user runs
|
||||
/setup-files once in their own DM).
|
||||
author: Ramón Fernández
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``. Using the
|
||||
# rich-dict form lets us contribute description/url/prompt metadata so users
|
||||
# see helpful guidance instead of the auto-generated fallback text.
|
||||
requires_env:
|
||||
- name: GOOGLE_CHAT_PROJECT_ID
|
||||
description: "GCP project ID hosting the Pub/Sub topic for Chat events. Falls back to GOOGLE_CLOUD_PROJECT."
|
||||
prompt: "GCP project ID"
|
||||
url: "https://console.cloud.google.com/"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_SUBSCRIPTION_NAME
|
||||
description: "Full Pub/Sub subscription path: projects/<proj>/subscriptions/<sub>. Legacy alias: GOOGLE_CHAT_SUBSCRIPTION."
|
||||
prompt: "Pub/Sub subscription name"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON
|
||||
description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS."
|
||||
prompt: "Path to SA JSON (or empty for ADC)"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: GOOGLE_CHAT_ALLOWED_USERS
|
||||
description: "Comma-separated user emails allowed to interact with the bot."
|
||||
prompt: "Allowed user emails (comma-separated)"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_HOME_CHANNEL
|
||||
description: "Default space for cron / notification delivery (e.g. spaces/AAAA...)."
|
||||
prompt: "Home space ID (or empty)"
|
||||
password: false
|
||||
@@ -653,6 +653,57 @@ def is_connected(config) -> bool:
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def _env_enablement() -> dict | None:
|
||||
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
||||
|
||||
Called by the platform registry's env-enablement hook (landed in the
|
||||
generic-plugin-interface migration) BEFORE adapter construction, so
|
||||
``gateway status`` and ``get_connected_platforms()`` reflect env-only
|
||||
configuration without instantiating the IRC client. Returns ``None``
|
||||
when IRC isn't minimally configured; the caller skips auto-enabling.
|
||||
|
||||
The special ``home_channel`` key in the returned dict is handled by
|
||||
the core hook — it becomes a proper ``HomeChannel`` dataclass on the
|
||||
``PlatformConfig`` rather than being merged into ``extra``.
|
||||
"""
|
||||
server = os.getenv("IRC_SERVER", "").strip()
|
||||
channel = os.getenv("IRC_CHANNEL", "").strip()
|
||||
if not (server and channel):
|
||||
return None
|
||||
seed: dict = {
|
||||
"server": server,
|
||||
"channel": channel,
|
||||
}
|
||||
port = os.getenv("IRC_PORT", "").strip()
|
||||
if port:
|
||||
try:
|
||||
seed["port"] = int(port)
|
||||
except ValueError:
|
||||
pass
|
||||
nickname = os.getenv("IRC_NICKNAME", "").strip()
|
||||
if nickname:
|
||||
seed["nickname"] = nickname
|
||||
use_tls = os.getenv("IRC_USE_TLS", "").strip().lower()
|
||||
if use_tls:
|
||||
seed["use_tls"] = use_tls in ("1", "true", "yes")
|
||||
# Passwords live in PlatformConfig.extra as well for back-compat with
|
||||
# existing config.yaml users; env-reads at construct time still win.
|
||||
if os.getenv("IRC_SERVER_PASSWORD"):
|
||||
seed["server_password"] = os.getenv("IRC_SERVER_PASSWORD")
|
||||
if os.getenv("IRC_NICKSERV_PASSWORD"):
|
||||
seed["nickserv_password"] = os.getenv("IRC_NICKSERV_PASSWORD")
|
||||
# Optional home-channel (usually the same as IRC_CHANNEL, but can be a
|
||||
# dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs
|
||||
# with ``deliver=irc`` have a sensible target without extra config.
|
||||
home = os.getenv("IRC_HOME_CHANNEL") or channel
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("IRC_HOME_CHANNEL_NAME", home),
|
||||
}
|
||||
return seed
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
@@ -665,6 +716,14 @@ def register(ctx):
|
||||
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
||||
install_hint="No extra packages needed (stdlib only)",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration — seeds PlatformConfig.extra with
|
||||
# server/channel/port/tls + home_channel so env-only setups show
|
||||
# up in gateway status without instantiating the adapter.
|
||||
env_enablement_fn=_env_enablement,
|
||||
# Cron home-channel delivery support. IRC_HOME_CHANNEL defaults to
|
||||
# IRC_CHANNEL (see _env_enablement), so cron jobs with
|
||||
# deliver=irc route to the joined channel by default.
|
||||
cron_deliver_env_var="IRC_HOME_CHANNEL",
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="IRC_ALLOWED_USERS",
|
||||
allow_all_env="IRC_ALLOW_ALL_USERS",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: irc-platform
|
||||
label: IRC
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
@@ -7,7 +8,47 @@ description: >
|
||||
(or DMs) and the Hermes agent. No external dependencies — uses
|
||||
Python's stdlib asyncio for the IRC protocol.
|
||||
author: Nous Research
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- IRC_SERVER
|
||||
- IRC_CHANNEL
|
||||
- IRC_NICKNAME
|
||||
- name: IRC_SERVER
|
||||
description: "IRC server hostname (e.g. irc.libera.chat)"
|
||||
prompt: "IRC server"
|
||||
password: false
|
||||
- name: IRC_CHANNEL
|
||||
description: "Channel to join (e.g. #hermes — comma-separate for multiple)"
|
||||
prompt: "IRC channel"
|
||||
password: false
|
||||
- name: IRC_NICKNAME
|
||||
description: "Bot nickname on IRC (default: hermes-bot)"
|
||||
prompt: "Bot nickname"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: IRC_PORT
|
||||
description: "IRC server port (default: 6697 with TLS, 6667 without)"
|
||||
prompt: "IRC port"
|
||||
password: false
|
||||
- name: IRC_USE_TLS
|
||||
description: "Use TLS for the IRC connection (1/true/yes to enable, default: true on port 6697)"
|
||||
prompt: "Use TLS? (true/false)"
|
||||
password: false
|
||||
- name: IRC_SERVER_PASSWORD
|
||||
description: "Server password for the IRC PASS command (optional)"
|
||||
prompt: "Server password (optional)"
|
||||
password: true
|
||||
- name: IRC_NICKSERV_PASSWORD
|
||||
description: "NickServ password for automatic IDENTIFY on connect (optional)"
|
||||
prompt: "NickServ password (optional)"
|
||||
password: true
|
||||
- name: IRC_ALLOWED_USERS
|
||||
description: "Comma-separated IRC nicks allowed to talk to the bot"
|
||||
prompt: "Allowed nicks (comma-separated)"
|
||||
password: false
|
||||
- name: IRC_ALLOW_ALL_USERS
|
||||
description: "Allow anyone in the channel to talk to the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: IRC_HOME_CHANNEL
|
||||
description: "Channel for cron / notification delivery (defaults to IRC_CHANNEL)"
|
||||
prompt: "Home channel (or empty)"
|
||||
password: false
|
||||
|
||||
@@ -152,6 +152,42 @@ def is_connected(config) -> bool:
|
||||
return validate_config(config)
|
||||
|
||||
|
||||
def _env_enablement() -> dict | None:
|
||||
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
||||
|
||||
Called by the platform registry's env-enablement hook BEFORE adapter
|
||||
construction, so ``gateway status`` and ``get_connected_platforms()``
|
||||
reflect env-only configuration without instantiating the Teams SDK.
|
||||
Returns ``None`` when Teams isn't minimally configured.
|
||||
|
||||
The special ``home_channel`` key in the returned dict becomes a proper
|
||||
``HomeChannel`` dataclass on the ``PlatformConfig`` via the core hook.
|
||||
"""
|
||||
client_id = os.getenv("TEAMS_CLIENT_ID", "").strip()
|
||||
client_secret = os.getenv("TEAMS_CLIENT_SECRET", "").strip()
|
||||
tenant_id = os.getenv("TEAMS_TENANT_ID", "").strip()
|
||||
if not (client_id and client_secret and tenant_id):
|
||||
return None
|
||||
seed: dict = {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
port = os.getenv("TEAMS_PORT", "").strip()
|
||||
if port:
|
||||
try:
|
||||
seed["port"] = int(port)
|
||||
except ValueError:
|
||||
pass
|
||||
home = os.getenv("TEAMS_HOME_CHANNEL", "").strip()
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("TEAMS_HOME_CHANNEL_NAME", "Home"),
|
||||
}
|
||||
return seed
|
||||
|
||||
|
||||
# Keep the old name as an alias so existing test imports don't break.
|
||||
check_teams_requirements = check_requirements
|
||||
|
||||
@@ -371,8 +407,25 @@ class TeamsAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
# Only authorized users may click approval buttons.
|
||||
# Default-deny: require either TEAMS_ALLOWED_USERS or an explicit
|
||||
# TEAMS_ALLOW_ALL_USERS=true opt-in. Without one of these set, the
|
||||
# bot silently treated every clicker as authorized — meaning any
|
||||
# Teams user who could message the bot could approve dangerous commands.
|
||||
allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip()
|
||||
if allowed_csv:
|
||||
allow_all = os.getenv("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
if not allow_all:
|
||||
if not allowed_csv:
|
||||
logger.warning(
|
||||
"[teams] card action rejected: TEAMS_ALLOWED_USERS not configured "
|
||||
"and TEAMS_ALLOW_ALL_USERS not set — default deny"
|
||||
)
|
||||
return InvokeResponse(
|
||||
status=200,
|
||||
body=AdaptiveCardActionMessageResponse(
|
||||
value="⛔ Approval buttons require TEAMS_ALLOWED_USERS to be configured."
|
||||
),
|
||||
)
|
||||
from_account = ctx.activity.from_
|
||||
clicker_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "")
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
@@ -685,6 +738,14 @@ def register(ctx) -> None:
|
||||
required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"],
|
||||
install_hint="pip install microsoft-teams-apps aiohttp",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration — seeds PlatformConfig.extra with
|
||||
# client_id/secret/tenant + port + home_channel so env-only setups
|
||||
# show up in gateway status without instantiating the Teams SDK.
|
||||
env_enablement_fn=_env_enablement,
|
||||
# Cron home-channel delivery support. Lets deliver=teams cron
|
||||
# jobs route to the configured Teams chat/channel without editing
|
||||
# cron/scheduler.py's hardcoded sets.
|
||||
cron_deliver_env_var="TEAMS_HOME_CHANNEL",
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="TEAMS_ALLOWED_USERS",
|
||||
allow_all_env="TEAMS_ALLOW_ALL_USERS",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: teams-platform
|
||||
label: Microsoft Teams
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
@@ -7,7 +8,41 @@ description: >
|
||||
between Teams chats (personal DMs, group chats, channel posts) and
|
||||
the Hermes agent. Supports Adaptive Card approval prompts.
|
||||
author: Aamir Jawaid
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- TEAMS_CLIENT_ID
|
||||
- TEAMS_CLIENT_SECRET
|
||||
- TEAMS_TENANT_ID
|
||||
- name: TEAMS_CLIENT_ID
|
||||
description: "Azure AD application (Bot Framework) client ID"
|
||||
prompt: "Teams / Azure AD client ID"
|
||||
url: "https://portal.azure.com/"
|
||||
password: false
|
||||
- name: TEAMS_CLIENT_SECRET
|
||||
description: "Azure AD application client secret"
|
||||
prompt: "Teams / Azure AD client secret"
|
||||
url: "https://portal.azure.com/"
|
||||
password: true
|
||||
- name: TEAMS_TENANT_ID
|
||||
description: "Azure AD tenant ID hosting the bot application"
|
||||
prompt: "Teams / Azure AD tenant ID"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: TEAMS_PORT
|
||||
description: "Webhook listen port (Bot Framework default: 3978)"
|
||||
prompt: "Webhook port"
|
||||
password: false
|
||||
- name: TEAMS_ALLOWED_USERS
|
||||
description: "Comma-separated Teams user IDs / UPNs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: TEAMS_ALLOW_ALL_USERS
|
||||
description: "Allow any Teams user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: TEAMS_HOME_CHANNEL
|
||||
description: "Default chat/channel ID for cron / notification delivery"
|
||||
prompt: "Home channel (or empty)"
|
||||
password: false
|
||||
- name: TEAMS_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Teams home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
|
||||
Reference in New Issue
Block a user