diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx
index 710d01b1..3f67bcba 100644
--- a/canvas/src/components/TemplatePalette.tsx
+++ b/canvas/src/components/TemplatePalette.tsx
@@ -400,6 +400,11 @@ export function TemplatePalette() {
+ {/* Org templates live INSIDE the scroll container so an
+ * expanded list (15+ entries) is reachable instead of
+ * overflowing the fixed footer below. */}
+
+
{loading && (
@@ -467,7 +472,6 @@ export function TemplatePalette() {
-
{/* Content — both panels are always in the DOM so aria-controls targets exist.
- The inactive panel is hidden via the HTML `hidden` attribute (removed from
- display and accessibility tree, but present in the DOM for WCAG 4.1.2). */}
-
+ Inactive panel is hidden via a conditional `hidden` Tailwind class
+ (display: none) because the native HTML `hidden` attribute is
+ overridden by the panel's own `flex` utility — that's why both
+ sections used to render stacked. */}
+
-
+
diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx
index f219653f..ed9b82d3 100644
--- a/canvas/src/components/tabs/ConfigTab.tsx
+++ b/canvas/src/components/tabs/ConfigTab.tsx
@@ -241,15 +241,53 @@ export function ConfigTab({ workspaceId }: Props) {
setSuccess(false);
try {
const content = rawMode ? rawDraft : toYaml(config);
- await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
+ const runtimeManagesOwnConfig = RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "");
+ // Only write the platform-managed config.yaml when the runtime
+ // actually consumes it. Hermes + external runtimes manage their
+ // own config file inside the container, so writing this one is a
+ // no-op at best and can fail with 404 if config.yaml was never
+ // created for this workspace.
+ if (!runtimeManagesOwnConfig) {
+ await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
+ }
- // If runtime changed, update it in the DB so restart uses the correct image
- const newRuntime = rawMode
- ? (parseYaml(rawDraft).runtime as string || "")
- : (config.runtime || "");
- const oldRuntime = (parseYaml(originalYaml).runtime as string || "");
- if (newRuntime && newRuntime !== oldRuntime) {
- await api.patch(`/workspaces/${workspaceId}`, { runtime: newRuntime });
+ // DB-backed fields (name, tier, runtime, model) live on the
+ // workspace row, NOT in config.yaml. Fire separate PATCHes for
+ // the ones that actually changed — otherwise a Hermes user edits
+ // the form, hits Save, sees the request succeed, then watches the
+ // values snap back on the next reload because the workspace row
+ // never heard about the change.
+ const oldParsed = parseYaml(originalYaml);
+ const nextParsed = rawMode ? parseYaml(rawDraft) : null;
+ const effective = nextParsed
+ ? { ...DEFAULT_CONFIG, ...nextParsed } as ConfigData
+ : config;
+ const dbPatch: Record = {};
+ if (effective.name && effective.name !== oldParsed.name) {
+ dbPatch.name = effective.name;
+ }
+ if (effective.tier && effective.tier !== (oldParsed.tier ?? null)) {
+ dbPatch.tier = effective.tier;
+ }
+ const oldRuntime = (oldParsed.runtime as string) || "";
+ if (effective.runtime && effective.runtime !== oldRuntime) {
+ dbPatch.runtime = effective.runtime;
+ }
+ if (Object.keys(dbPatch).length > 0) {
+ await api.patch(`/workspaces/${workspaceId}`, dbPatch);
+ }
+
+ // Model has its own endpoint (separate from the general workspace
+ // PATCH) because the runtime may need to validate it against the
+ // template's supported models list.
+ const oldModel = (oldParsed.model as string) || "";
+ if (effective.model && effective.model !== oldModel) {
+ try {
+ await api.put(`/workspaces/${workspaceId}/model`, { model: effective.model });
+ } catch (e) {
+ // Non-fatal — log and continue so the rest of the save commits.
+ console.warn("ConfigTab: model PATCH failed", e);
+ }
}
setOriginalYaml(content);
diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx
index 132989df..d046f070 100644
--- a/canvas/src/components/tabs/SkillsTab.tsx
+++ b/canvas/src/components/tabs/SkillsTab.tsx
@@ -68,22 +68,32 @@ export function SkillsTab({ data }: Props) {
const loadInstalled = useCallback(async () => {
try {
const result = await api.get(`/workspaces/${workspaceId}/plugins`);
- if (mountedRef.current) setInstalled(result);
- } catch { /* ignore */ }
+ if (mountedRef.current) setInstalled(Array.isArray(result) ? result : []);
+ } catch (e) {
+ console.warn("SkillsTab: installed plugins load failed", e);
+ }
}, [workspaceId]);
const loadRegistry = useCallback(async () => {
try {
const result = await api.get("/plugins");
- if (mountedRef.current) setRegistry(result);
- } catch { /* ignore */ }
+ if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []);
+ } catch (e) {
+ // Registry is the AVAILABLE PLUGINS list. Silent failure here
+ // left the user seeing "No plugins in registry" with no clue
+ // it was a fetch error — log it so devtools shows the cause.
+ console.warn("SkillsTab: registry load failed", e);
+ }
}, []);
const loadSourceSchemes = useCallback(async () => {
try {
const result = await api.get("/plugins/sources");
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
- } catch { /* ignore — falls back to "local only" UX */ }
+ } catch (e) {
+ console.warn("SkillsTab: plugin sources load failed", e);
+ // Falls back to "local only" UX — non-fatal.
+ }
}, []);
useEffect(() => {
diff --git a/workspace-server/internal/handlers/discovery.go b/workspace-server/internal/handlers/discovery.go
index bf55cc7d..e27c256f 100644
--- a/workspace-server/internal/handlers/discovery.go
+++ b/workspace-server/internal/handlers/discovery.go
@@ -330,6 +330,15 @@ func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID st
if !hasLive {
return nil // legacy / pre-upgrade
}
+ // Tier-1b dev-mode hatch — same escape hatch AdminAuth and
+ // WorkspaceAuth apply on a local Docker setup. Without this, the
+ // canvas Details tab can never load peers for a workspace that has
+ // registered its live token, producing the 401 the user sees.
+ // Gated by MOLECULE_ENV=development + empty ADMIN_TOKEN, so SaaS
+ // production stays strict.
+ if middleware.IsDevModeFailOpen() {
+ return nil
+ }
// Try session cookie auth first (SaaS canvas path).
// verifiedCPSession returns (valid, presented):
diff --git a/workspace-server/internal/middleware/devmode.go b/workspace-server/internal/middleware/devmode.go
index 2c226c75..a751da12 100644
--- a/workspace-server/internal/middleware/devmode.go
+++ b/workspace-server/internal/middleware/devmode.go
@@ -54,3 +54,12 @@ func isDevModeFailOpen() bool {
_, ok := devModeEnvValues[env]
return ok
}
+
+// IsDevModeFailOpen exposes isDevModeFailOpen to packages outside the
+// middleware module (handlers, discovery, etc.) so they can apply the
+// same Tier-1b escape hatch their sibling AdminAuth / WorkspaceAuth
+// already do. Keep every call site audit-tagged so security review can
+// grep them.
+func IsDevModeFailOpen() bool {
+ return isDevModeFailOpen()
+}