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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ product overview.

## Unreleased

- Gateway Settings now lets admins map short names to an engine and model. In Slack, select the bot mention and type `:astra` after Slack's inserted space (`@agent :astra`) to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change.

- Conversations can filter by Google Chat, Slack or Teams alongside search and Channels / DMs.
Teams channel names now use `#team-channel`, resolving missing names through the bot directory
on incoming messages while preserving conversation IDs, work folders and settings.
Expand Down
4 changes: 4 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# ChannelGate — Features

## Gateway model shortcuts

Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent :astra task` pins that engine and model to the current thread and runs the task; `@agent :astra` switches the thread without starting an agent turn. Slack's automatically inserted space after the mention is accepted. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply.

## Public documentation website

- `documentation/` builds a static Astro/Starlight documentation section for
Expand Down
11 changes: 11 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# ChannelGate — Test Plan

## Model shortcuts acceptance (2026-10-06)

- [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings.
- [x] Browser: `test/model-shortcuts-browser.test.js` ran in Chromium against a disposable admin API and store. Added a Codex shortcut from the current catalog, saved and reloaded it, repointed its model and engine, and removed it; no page errors.
- [x] Pipeline: `test/model-shortcut-pipeline.test.js` ran the real Slack pipeline with spawned Claude/Codex fixture CLIs. Verified Slack's inserted space, model flags on new/resumed turns, a Codex→Claude switch, stable pins after repointing a shortcut, stripped prefixes on runtime-card retries, and refusal before pin changes for runtime permissions, disabled engines and a dedicated Codex login. These are integration fixtures, not live provider acceptance.
- Integration check: static checks passed (781 JavaScript files). Full suite: 3,211 passed, 30 skipped, one existing failure in `test/readme.test.js` asserting a single non-partner website link; the new documentation link makes the count two. `README.md` and that test are identical to the beta base. Focused shortcut tests and the explicit Chromium run passed.
- [ ] Live: In gateway Settings, add `astra` → Codex / a currently offered model and `opus` → Claude / a currently offered model. Save and reload; verify both mappings remain. Repoint `astra` to another offered Codex model, save and reload.
- [ ] Live: In a Slack channel with runtime changes allowed for the author, select the bot from Slack's mention picker and send `@agent :astra summarize this thread` as a new root message, retaining Slack's inserted space. Verify the reply uses the selected Codex model and the thread settings show the pin. Reply `@agent :opus` in the same thread; verify the switch acknowledgement and the next ordinary request uses the selected Claude model with earlier thread context available.
- [ ] Live: In a channel restricted to admin runtime changes, verify a non-admin's `@agent :astra task` is refused and leaves the thread runtime unchanged. Verify an unknown name is refused. In a channel with its own Codex login, verify a Claude shortcut is refused.
- [ ] Live: Run the reverse engine path with `@agent :opus task` as a new root message and `@agent :astra` within that thread, confirming the Codex answer retains earlier context. Check both engines' recorded model and effort (the shortcut clears a previous effort pin).

## Complete functionality handbook (2026-10-06)

- [x] Inventory reconciliation: all 45 functional areas map to detailed guides; 106 feature
Expand Down
60 changes: 59 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,60 @@ function syncModelOptions({ engineSelect, modelSelect, value, engine, blankLabel
modelSelect.value = options.some(([v]) => v === current) ? current : "";
}

function addModelShortcutRow(name = "", target = {}) {
const box = document.getElementById("model-shortcuts-editor");
const row = document.createElement("div");
row.className = "model-shortcut-row";
row.style.cssText = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0";
row.innerHTML = `<input class="shortcut-name" type="text" maxlength="24" placeholder="astra" aria-label="Shortcut name" style="max-width:120px" />
<select class="shortcut-engine" aria-label="Engine"></select>
<select class="shortcut-model" aria-label="Model"></select>
<button type="button" class="clear-tok shortcut-remove">Remove</button>`;
const engineSelect = row.querySelector(".shortcut-engine");
const modelSelect = row.querySelector(".shortcut-model");
engineSelect.innerHTML = engineOptionsHtml();
if (target.engine && ![...engineSelect.options].some((option) => option.value === target.engine)) {
const option = document.createElement("option");
option.value = target.engine;
option.textContent = `${target.engine} (disabled)`;
engineSelect.add(option);
}
engineSelect.value = target.engine || engineSelect.options[0]?.value || "claude";
const fillModels = (value = "") => {
const engine = engineSelect.value;
const manifestModels = ENGINE_MANIFESTS.find((m) => m.id === engine)?.models || [];
const options = manifestModels.length ? manifestModels.map((m) => [m.value, m.label || m.value]) : [...(MODEL_OPTIONS[engine] || [])];
if (value && !options.some(([id]) => id === value)) options.push([value, value]);
modelSelect.innerHTML = options.map(([id, label]) => `<option value="${escapeHtml(id)}">${escapeHtml(label)}</option>`).join("");
modelSelect.value = value && options.some(([id]) => id === value) ? value : (options[0]?.[0] || "");
};
row.querySelector(".shortcut-name").value = name;
fillModels(target.model);
engineSelect.addEventListener("change", () => fillModels());
row.querySelector(".shortcut-remove").addEventListener("click", () => { row.remove(); markSettingsDirty(); });
box.append(row);
}

function paintModelShortcuts(shortcuts) {
const box = document.getElementById("model-shortcuts-editor");
box.replaceChildren();
for (const [name, target] of Object.entries(shortcuts || {})) addModelShortcutRow(name, target);
document.getElementById("add-model-shortcut").disabled = false;
}

function collectModelShortcuts() {
const shortcuts = {};
for (const row of document.querySelectorAll("#model-shortcuts-editor .model-shortcut-row")) {
const name = row.querySelector(".shortcut-name").value.trim().toLowerCase();
if (!/^[a-z][a-z0-9_-]{0,23}$/.test(name)) throw new Error(`Invalid model shortcut name: ${name || "(blank)"}`);
if (Object.hasOwn(shortcuts, name)) throw new Error(`Duplicate model shortcut: ${name}`);
shortcuts[name] = { engine: row.querySelector(".shortcut-engine").value, model: row.querySelector(".shortcut-model").value };
}
return shortcuts;
}

document.getElementById("add-model-shortcut").addEventListener("click", () => { addModelShortcutRow(); markSettingsDirty(); });

function checkboxList(container, items, selected, valueKey = "value", labelKey = "label") {
container.innerHTML = "";
if (!items.length) {
Expand Down Expand Up @@ -3615,6 +3669,7 @@ function readSettingsForm() {
defaultCodexModel: document.getElementById("set-default-codex-model").value,
...collectQwenProviders(),
modelChangeAccess: document.getElementById("set-model-change-access").value,
modelShortcuts: collectModelShortcuts(),
engineEnabled: { ...ENGINE_ENABLED },
engineFallback: document.getElementById("set-engine-fallback").checked,
engineFallbackMode: document.getElementById("set-engine-fallback-mode").value,
Expand Down Expand Up @@ -3748,6 +3803,7 @@ function paintSettings(s) {
syncModelOptions({ modelSelect: document.getElementById("set-default-claude-model"), engine: "claude", value: s.defaultClaudeModel || "", blankLabel: "CLI default" });
syncModelOptions({ modelSelect: document.getElementById("set-default-codex-model"), engine: "codex", value: s.defaultCodexModel || "", blankLabel: "CLI default" });
document.getElementById("set-model-change-access").value = s.modelChangeAccess || "admins";
paintModelShortcuts(s.modelShortcuts);
document.getElementById("set-engine-fallback").checked = s.engineFallback !== false;
document.getElementById("set-engine-fallback-mode").value = s.engineFallbackMode || "auto";
document.getElementById("set-show-message-cost").checked = s.showMessageCost !== false;
Expand Down Expand Up @@ -4156,7 +4212,9 @@ function bindSettings() {
saved.textContent = "saving…";
// Only the fields this admin actually changed. Everything else is left to whatever the daemon
// holds now — the whole point: an unrelated save must not revert another writer.
const form = readSettingsForm();
let form;
try { form = readSettingsForm(); }
catch (e) { saved.textContent = "✗ " + e.message; return; }
const patch = diffSettingsPayload(SETTINGS_BASELINE || {}, form);
const newPw = form.adminPassword || "";
// Only (re)connect Slack when a Slack token was actually changed in this save — a normal
Expand Down
6 changes: 6 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,12 @@ <h4>Runtime changes</h4>
<em class="state">Controls both channel-wide and thread-only <code>/model</code> changes. DM users can always customize their own runtime.</em>
</label>
</div>
<div class="setcard">
<h4>Model shortcuts</h4>
<p class="desc">Mention the bot, then type a colon and a name after Slack's inserted space, for example <code>@agent :astra review this</code>. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply.</p>
<div id="model-shortcuts-editor"></div>
<button type="button" class="clear-tok" id="add-model-shortcut" disabled>Add shortcut</button>
</div>
<div class="setcard">
<h4>Container runtime</h4>
<p class="desc">Every channel's engine processes run inside <strong>its own long-lived Linux container</strong> — separate HOME, separate CLI logins, its own process namespace, and none of the host's files reachable beyond the channel's own folders. This is the only runtime: the daemon needs <code>podman</code> or <code>docker</code> on this machine plus a built image (<code>npm run build:image</code>). Admin-mode channels run in their container too; the permission bypass applies inside it, never on the host.</p>
Expand Down
19 changes: 19 additions & 0 deletions src/config/model-shortcuts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Gateway-wide names for explicit, thread-scoped Slack model choices.
export const MODEL_SHORTCUT_NAME_RE = /^[a-z][a-z0-9_-]{0,23}$/;
export const MAX_MODEL_SHORTCUTS = 32;

export function modelShortcutsFromSettings(settings) {
const raw = settings?.modelShortcuts;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return Object.fromEntries(Object.entries(raw).filter(([name, target]) =>
MODEL_SHORTCUT_NAME_RE.test(name) && target && typeof target === "object" &&
typeof target.engine === "string" && typeof target.model === "string"
));
}

// Slack gives the bot mention as <@ID>; stripMentions leaves the colon at the start.
// Require a separating space before the task, so ":astraight" never selects ":astra".
export function parseModelShortcut(text) {
const match = /^:\s*([a-z][a-z0-9_-]{0,23})(?=\s|$)\s*([\s\S]*)$/i.exec(String(text || "").trim());
return match ? { name: match[1].toLowerCase(), task: match[2].trim() } : null;
}
6 changes: 6 additions & 0 deletions src/config/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { QWEN_PROVIDERS, qwenProvider } from "../engines/qwen.js";
// The default container image ref lives with the image module (a dependency-free leaf) so the
// transactional updater can name the same image without importing this file's database layer.
import { CONTAINER_DEFAULT_IMAGE } from "../runtimes/container/image.js";
import { modelShortcutsFromSettings } from "./model-shortcuts.js";

// settings.json key → environment variable it feeds.
const ENV_MAP = {
Expand Down Expand Up @@ -308,6 +309,10 @@ export function getDefaultModel(engine) {
return typeof v === "string" ? v.trim() : "";
}

export function getModelShortcuts() {
return modelShortcutsFromSettings(getSettings());
}

// Who may use Slack's /model wizard in a CHANNEL (both channel-wide and thread-scoped picks).
// Everyone reaching this gate has already passed the channel's normal authorization policy, so
// "users" means every authorized channel user (approved members plus explicit guest grants).
Expand Down Expand Up @@ -1032,6 +1037,7 @@ export function settingsForApi() {
// instead of carrying a hard-coded copy of the table.
qwenProviders: qwenProviderSettings(),
modelChangeAccess: getModelChangeAccess(),
modelShortcuts: getModelShortcuts(),
engineEnabled: getEngineEnabledMap(),
engineFallback: getEngineFallback(),
engineFallbackMode: getEngineFallbackMode(),
Expand Down
2 changes: 1 addition & 1 deletion src/slack/help.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 40 additions & 2 deletions src/slack/message-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import { noteBotReply, noteUserActivity } from "../gateway/nudges.js";
import { applyLoopWakeup, stopLoops, stopThreadLoops } from "../gateway/loops.js";
import { buildPendingReportForUser } from "../gateway/followups.js";

import { resolveSlackConfig, getProgressView, getContextWindow, getTrustedBotApps, getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges, getSlackAdminUserToken, canChangeChannelRuntime, getWhisperEnabled, getEngineFallbackMode } from "../config/settings.js";
import { resolveSlackConfig, getProgressView, getContextWindow, getTrustedBotApps, getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges, getSlackAdminUserToken, canChangeChannelRuntime, getWhisperEnabled, getEngineFallbackMode, getModelShortcuts, isEngineEnabled } from "../config/settings.js";
import { parseModelShortcut } from "../config/model-shortcuts.js";
import { mdToMrkdwn, resolveMentions } from "./format.js";
import { answerImageBlocks, shareAnswerImageFiles } from "./images.js";
import { appendSlackTables, extractSlackTables, formatSlackTables } from "./block-content.js";
Expand Down Expand Up @@ -1082,6 +1083,43 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// stamp), and the session-stamp comparison after the directive blocks (covers switches made
// out-of-band — the /model wizard, the admin UI, a flipped gateway default).
let engineSwitched = false;
// A colon immediately after the bot mention selects a configured model shortcut. The
// setting is read for each message, so admins can repoint names without restarting Slack.
// Persist the resolved target on this thread; later edits to the shortcut affect new picks.
const shortcut = !syntheticFork ? parseModelShortcut(prompt) : null;
// A retry/switch card already selected the runtime. Keep its choice, but remove the
// original shortcut prefix before replaying the task to the engine.
if (shortcut && engineChoice) prompt = shortcut.task;
if (shortcut && !engineChoice) {
const shortcuts = getModelShortcuts();
const target = Object.hasOwn(shortcuts, shortcut.name) ? shortcuts[shortcut.name] : null;
if (!target) {
await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `Unknown model shortcut \`:${shortcut.name}\`. Ask an admin to add it in Settings → Model shortcuts.` });
return;
}
if (!isDM && !canChangeChannelRuntime(authorIsAdmin)) {
await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "Only admins can change the model in this channel." });
return;
}
if (!isEngineEnabled(target.engine) || !modelBelongsToEngine(target.model, target.engine)) {
await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `Model shortcut \`:${shortcut.name}\` needs an enabled engine and valid model. Update it in Settings.` });
return;
}
if (meta.codexAuthSource === "channel" && target.engine !== "codex") {
await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "This channel uses its own Codex login, so its engine stays Codex." });
return;
}
engineSwitched = (await getThreadEngine(entry.slug, threadKey)) !== target.engine;
await setThreadEngine(entry.slug, threadKey, target.engine);
await setThreadModel(entry.slug, threadKey, target.model);
// Effort is model-specific. A prior thread pin must not leak into a new shortcut.
await setThreadEffort(entry.slug, threadKey, "");
prompt = shortcut.task;
if (!prompt && files.length === 0) {
await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `✅ This thread now uses *${engineLabel(target.engine)}* · \`${target.model}\`.` });
return;
}
}
// A harness-switch card click: the person chose where this message runs. "Switch" pins the
// thread there (the same per-thread choice the `claude` / `codex` directive makes); "try
// again" leaves the thread as it is. Any engine directive in the text was already applied
Expand All @@ -1101,7 +1139,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// switch phrase ("try again with codex", "switch to codex", "use claude") — a switch verb
// immediately before the engine name, so an incidental mention ("the codex CLI") won't flip it.
// It sticks until changed; the rest of the message is the task.
if (!syntheticFork && files.length === 0) {
if (!syntheticFork && !shortcut && files.length === 0) {
const trimmed = prompt.trim();
const anchored = new RegExp(`^(${engineIdAlternation()})\\b[\\s:,.;–—-]*([\\s\\S]*)$`, "i").exec(trimmed);
// Only a switch INTENT near the START counts (index ≤ 12, allowing a short lead like
Expand Down
Loading
Loading