revert(agent): remove per-agent local skill toggle (MUL-2603) (#3286)

* Revert "feat(agents): hide skills_local toggle for runtimes that don't honour it (MUL-2603) (#3276)"

This reverts commit 0b50c5a209.

Co-authored-by: multica-agent <[email protected]>

* Revert "fix(agent): surface host OAuth token via env var on macOS isolation (MUL-2603) (#3267)"

This reverts commit a67bf81225.

Co-authored-by: multica-agent <[email protected]>

* Revert "fix(agents): tighten skills-tab intro and drop redundant import hint (#3265)"

This reverts commit d8075a5775.

Co-authored-by: multica-agent <[email protected]>

* Revert "fix(agent): mirror $HOME/.claude.json into isolated config dir (MUL-2661) (#3261)"

This reverts commit 40da88fc16.

Co-authored-by: multica-agent <[email protected]>

* Revert "feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603) (#3200)"

This reverts commit 960befa56f.

Co-authored-by: multica-agent <[email protected]>

* Add migration cleanup for reverted agent skills toggle

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica Eve
2026-05-26 17:00:01 +08:00
committed by GitHub
co-authored by multica-agent Eve
parent ae11f290b4
commit 744b474199
39 changed files with 81 additions and 3035 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`). For Claude Code and Codex, host-machine skills under `~/.claude/skills/` and `~/.codex/skills/` are **merged by default** for each agent (so existing personal workflows keep working) — toggle the per-agent **Allow host-machine Skills** switch off to isolate a shared agent against a broken local skill on one operator's machine (GitHub #3052). Other runtimes ignore this setting — the daemon does not actively manage user-level skill discovery for them today.
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
-18
View File
@@ -16,24 +16,6 @@ Multica supports two skill sources:
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
### Per-agent host-skill merge toggle
By default Multica **merges** the host machine's user-global skill directory into the agent (so existing personal workflows that rely on locally installed Claude skills keep working out of the box). You can flip the toggle to **ignore** when a shared agent should be hardened against a broken local skill on one operator's machine — that scenario crashes the Claude CLI before it ever reads a prompt (silent `broken pipe` exits, see GitHub #3052).
Each agent carries a `skills_local` switch you can change from the **Create Agent** dialog or the agent's **Skills** tab:
- **On — Merge (default)**: the runtime inherits the full `~/.claude/` directory, including its `skills/`. Matches the pre-MUL-2603 behavior — recommended for personal agents that intentionally rely on locally installed skills.
- **Off — Ignore**: the runtime launches against a per-task config directory that mirrors the host's effective Claude config dir *except* for `skills/`. The source is resolved with precedence agent `custom_env` `CLAUDE_CONFIG_DIR` > parent process `CLAUDE_CONFIG_DIR` > `~/.claude/`, so an operator who pinned Claude at a managed install keeps the right credentials and settings. Login credentials (`.credentials.json`), global settings, plugins, and agents pass through, so Claude Code authentication keeps working — only the user-global skills directory is hidden from the CLI. On Windows hosts without Developer Mode the mirror falls back to directory junctions and hardlink/copy for files, so the passthrough still works without elevated privileges. Recommended for any shared agent where host-skill safety matters.
Workspace skills under `{workDir}/.claude/skills/` (and the equivalent workdir-scoped paths for other runtimes) are loaded regardless of the toggle — this switch only governs the host's user-global skills directory.
**Which runtimes the toggle covers today:** Claude Code and Codex are the two runtimes where the daemon actively manages user-skill discovery, so they are the two that honor the toggle:
- **Claude Code** — when set to **Off**, `CLAUDE_CONFIG_DIR` points at a per-task scratch dir that mirrors the host's effective Claude config dir *except* for `skills/` (see above).
- **Codex** — Codex always runs inside a per-task `CODEX_HOME`. When set to **On (default)** the daemon seeds that `CODEX_HOME` with user-installed skills from the shared `~/.codex/skills/`; when set to **Off** the seed step is skipped, so the Codex CLI sees only workspace skills.
For every other runtime (Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi / OpenCode / OpenClaw) the daemon leaves the user's `$HOME` untouched and does not actively manage user-level skill discovery — whether that runtime reads any user-level skill directory is entirely up to its own CLI. **The toggle is hidden in the Create Agent dialog and the Skills tab for these runtimes** to avoid implying behavior the daemon won't deliver today; a future change can extend per-runtime isolation as upstream CLIs ship the necessary knobs.
## Importing a skill
Workspace skills come from four sources:
-18
View File
@@ -16,24 +16,6 @@ Multica 支持两种 Skill 来源:
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
### 按智能体维度的"合并本机 Skill"开关
默认情况下 Multica **会**把本机用户级 skill 目录合并进智能体(保持 MUL-2603 之前的"继承本机"行为,避免误伤依赖本机 skill 的个人智能体)。如果某个共享智能体需要防御"某台机器上有问题的本机 skill"——这种情况会让 Claude CLI 还没读到 prompt 就崩(典型表现是 `broken pipe`,参见 GitHub #3052)——你可以把开关切到 ignore。
每个智能体都带有一个 `skills_local` 开关,可在 **创建 Agent** 弹窗或智能体的 **Skills** 标签页里切换:
- **开启 —— Merge(默认)**:运行时直接继承完整的 `~/.claude/`,包括 `skills/`。沿用 MUL-2603 之前的行为,适合个人智能体或明确依赖本机 skill 的场景。
- **关闭 —— Ignore**:运行时使用按任务隔离的配置目录,目录里通过软链镜像了主机实际生效的 Claude 配置目录中除 `skills/` 之外的全部内容。源目录的解析顺序为:agent `custom_env` 中的 `CLAUDE_CONFIG_DIR` > 父进程 `CLAUDE_CONFIG_DIR` > `~/.claude/`,因此把 Claude 固定到托管安装目录的运维者仍能拿到正确的凭据和设置。登录凭据(`.credentials.json`)、全局设置、插件、本地 agent 仍会被 CLI 看到,Claude Code 登录态不受影响,只屏蔽用户级 skill 目录。在未开启开发者模式的 Windows 主机上,软链失败时会自动回退为目录联结(junction)以及文件的硬链接/拷贝,保证无管理员权限时镜像仍然可用。**推荐在需要防御本机 skill 风险的共享智能体上启用**。
无论开关如何,工作区目录下的 `{workDir}/.claude/skills/`(以及其他 runtime 在工作目录下对应的路径)都会照常加载——这个开关只控制本机用户级 skill 目录。
**目前哪些运行时实际受开关影响:** 守护进程主动管理用户级 skill 发现的运行时是 Claude Code 和 Codex,所以开关对它们都生效:
- **Claude Code** —— 关闭时把 `CLAUDE_CONFIG_DIR` 指向按任务隔离的目录,里面镜像了主机有效的 Claude 配置目录中除 `skills/` 之外的内容(见上)。
- **Codex** —— Codex 一向运行在按任务隔离的 `CODEX_HOME` 下。开启(默认)时守护进程会把共享的 `~/.codex/skills/` seed 到该目录;关闭时跳过这一步,Codex CLI 只能看到工作区 skill。
对其它运行时(Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi / OpenCode / OpenClaw),守护进程既不改写用户的 `$HOME`,也不主动管理用户级 skill 发现——这些运行时是否读取任何用户级 skill 目录,完全由各自的 CLI 决定。**在创建 Agent 弹窗和 Skills 标签页里,这些运行时下不会展示该开关**,避免让用户误以为守护进程会替它们做隔离;待上游 CLI 提供必要的隔离开关后,可以再补齐。
## 导入 Skill
工作区 Skill 有四种来源:
-1
View File
@@ -7,4 +7,3 @@ export * from "./use-workspace-presence-prefetch";
export * from "./constants";
export * from "./visibility-label";
export * from "./use-workspace-agent-availability";
export * from "./skills-local-support";
@@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import { isSkillsLocalSupportedProvider } from "./skills-local-support";
describe("isSkillsLocalSupportedProvider", () => {
it("returns true for the providers whose runtime honours skills_local", () => {
expect(isSkillsLocalSupportedProvider("claude")).toBe(true);
expect(isSkillsLocalSupportedProvider("codex")).toBe(true);
});
it("returns false for every other provider in the daemon catalog", () => {
// Snapshot of the providers daemon recognises today (MUL-2603 thread).
// None of them currently enforce skills_local at exec time, so the UI
// hides the toggle for all of them.
for (const p of [
"copilot",
"opencode",
"openclaw",
"pi",
"cursor",
"kimi",
"kiro",
"gemini",
"hermes",
]) {
expect(isSkillsLocalSupportedProvider(p)).toBe(false);
}
});
it("returns false for missing / unknown provider values", () => {
expect(isSkillsLocalSupportedProvider(null)).toBe(false);
expect(isSkillsLocalSupportedProvider(undefined)).toBe(false);
expect(isSkillsLocalSupportedProvider("")).toBe(false);
expect(isSkillsLocalSupportedProvider("not-a-real-provider")).toBe(false);
});
});
@@ -1,16 +0,0 @@
// Providers whose runtime actually honours the per-agent `skills_local`
// toggle today. Claude isolates `~/.claude/skills` via `CLAUDE_CONFIG_DIR`;
// Codex isolates `~/.codex/skills` via per-task `CODEX_HOME`. Every other
// provider currently stores the field but treats it as a no-op at exec
// time, so surfacing the toggle in their agent pages would mislead users
// into thinking it gates host-skill access for them. See MUL-2603 discussion.
export const SKILLS_LOCAL_SUPPORTED_PROVIDERS = ["claude", "codex"] as const;
export function isSkillsLocalSupportedProvider(
provider: string | null | undefined,
): boolean {
if (!provider) return false;
return (SKILLS_LOCAL_SUPPORTED_PROVIDERS as readonly string[]).includes(
provider,
);
}
-27
View File
@@ -4,19 +4,6 @@ export type AgentRuntimeMode = "local" | "cloud";
export type AgentVisibility = "workspace" | "private";
/**
* Per-agent toggle controlling whether the runtime merges the host machine's
* user-global skill directory into the agent. "merge" (default) preserves the
* inherit-from-machine behavior; "ignore" isolates the runtime so a broken
* local skill on one operator's machine cannot crash a shared agent (#3052).
* Only the Claude and Codex runtimes honour the switch (Claude via
* CLAUDE_CONFIG_DIR, Codex via CODEX_HOME); the UI hides the toggle for
* other runtimes so users don't get a false sense of isolation. Older
* backends omit the field; clients MUST treat undefined as "merge" so the
* documented default wins on drift.
*/
export type AgentSkillsLocal = "ignore" | "merge";
// Runtime visibility is a separate axis from agent visibility — different
// vocabulary because it gates a different action. "private" (default) means
// only the runtime owner and workspace admins can bind agents to it;
@@ -169,14 +156,6 @@ export interface Agent {
* (MUL-2339).
*/
thinking_level?: string;
/**
* Per-agent toggle for merging the host machine's user-global skill
* directory (e.g. Claude's `~/.claude/skills/`) into the agent. Older
* backends that predate the column omit this field; consumers MUST
* default to `"merge"` on drift to match the documented platform
* default (#3052 hardening is opt-in via `"ignore"`).
*/
skills_local?: AgentSkillsLocal;
owner_id: string | null;
skills: AgentSkillSummary[];
created_at: string;
@@ -212,10 +191,6 @@ export interface CreateAgentRequest {
model?: string;
/** Optional runtime-native reasoning/effort token. See `Agent.thinking_level`. */
thinking_level?: string;
/** Per-agent host-skill merge toggle. Defaults to `"merge"` server-side
* when omitted (inherit-from-machine behavior); pass `"ignore"` to
* isolate the agent from the host's user-global skill directory. */
skills_local?: AgentSkillsLocal;
/** Optional template slug used by the onboarding agent picker. Surfaced
* as the `template` property on the `agent_created` PostHog event. */
template?: string;
@@ -321,8 +296,6 @@ export interface UpdateAgentRequest {
* runtime's provider enum, rejected with 400 if not recognised
*/
thinking_level?: string;
/** Update the host-skill merge toggle. Omit to leave unchanged. */
skills_local?: AgentSkillsLocal;
}
/**
-1
View File
@@ -4,7 +4,6 @@ export type {
AgentStatus,
AgentRuntimeMode,
AgentVisibility,
AgentSkillsLocal,
AgentTask,
AgentActivityBucket,
AgentRunCount,
@@ -164,7 +164,7 @@ export function AgentOverviewPane({
)}
{activeTab === "skills" && (
<TabContent>
<SkillsTab agent={agent} runtime={runtime} />
<SkillsTab agent={agent} />
</TabContent>
)}
{activeTab === "env" && (
@@ -261,49 +261,6 @@ describe("CreateAgentDialog runtime visibility gate", () => {
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
});
it("renders the skills_local toggle when the selected runtime is claude", () => {
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
owner_id: ME,
provider: "claude",
});
renderDialog([mine]);
expect(
screen.getByRole("switch", { name: /Allow locally installed skills/i }),
).toBeInTheDocument();
});
it("hides the skills_local toggle for runtimes that don't honour it", async () => {
// Other providers either already isolate (no need for the toggle) or
// currently ignore the field at exec time. Showing the toggle there
// would mislead users into thinking it gates host-skill access for
// their runtime. MUL-2603 follow-up.
const copilot = makeRuntime({
id: "rt-copilot",
name: "Copilot Runtime",
owner_id: ME,
provider: "copilot",
});
const { onCreate } = renderDialog([copilot]);
expect(
screen.queryByRole("switch", {
name: /Allow locally installed skills/i,
}),
).not.toBeInTheDocument();
// The payload must never carry skills_local for unsupported runtimes.
fireEvent.change(screen.getByPlaceholderText(/Deep Research Agent/i), {
target: { value: "Test" },
});
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].skills_local).toBeUndefined();
});
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
// Edge case: template points at a locked runtime AND the workspace
// has no usable alternatives in scope. The defense-in-depth gate on
@@ -7,14 +7,12 @@ import { ModelDropdown } from "./model-dropdown";
import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker";
import { InstructionsEditor } from "./instructions-editor";
import { SkillMultiSelect } from "./skill-multi-select";
import { SkillsLocalToggle } from "./skills-local-toggle";
import { AvatarPicker } from "./avatar-picker";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { workspaceKeys } from "@multica/core/workspace/queries";
import type {
Agent,
AgentSkillsLocal,
AgentVisibility,
RuntimeDevice,
MemberWithUser,
@@ -36,7 +34,6 @@ import {
AGENT_DESCRIPTION_MAX_LENGTH,
VISIBILITY_DESCRIPTION,
VISIBILITY_LABEL,
isSkillsLocalSupportedProvider,
} from "@multica/core/agents";
import { CharCounter } from "./char-counter";
import { useT } from "../../i18n";
@@ -95,13 +92,6 @@ export function CreateAgentDialog({
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(
() => new Set(template?.skills.map((s) => s.id) ?? []),
);
// Default to "merge" for new agents (matches the server-side default —
// inherit-from-machine behavior preserved). Duplicate mode carries the
// source agent's explicit "ignore" through so a hardened agent stays
// hardened.
const [skillsLocal, setSkillsLocal] = useState<AgentSkillsLocal>(
() => (template?.skills_local === "ignore" ? "ignore" : "merge"),
);
const [creating, setCreating] = useState(false);
// Duplicate-mode pre-fill: clone lands on the source agent's runtime so
@@ -127,13 +117,6 @@ export function CreateAgentDialog({
const selectedRuntimeLocked =
selectedRuntime != null &&
!isRuntimeUsableForUser(selectedRuntime, currentUserId);
// The skills_local switch only has runtime support on claude / codex
// today — everywhere else the field would be stored but ignored at
// exec time, so the toggle is hidden to avoid implying behavior the
// backend won't deliver. See MUL-2603.
const skillsLocalApplies = isSkillsLocalSupportedProvider(
selectedRuntime?.provider,
);
// Shared squad-join follow-up. Returns nothing — the caller has
// already shown its create-success toast; we only need to surface a
@@ -179,15 +162,6 @@ export function CreateAgentDialog({
model: model.trim() || undefined,
instructions: trimmedInstructions || undefined,
avatar_url: avatarUrl ?? undefined,
// Only send the toggle when the user opted into isolation AND the
// selected runtime actually honours it (claude / codex today).
// "merge" is the server-side default; omitting the field keeps the
// request body small and prevents older backends without the
// column from rejecting the request. Provider gating mirrors the
// toggle visibility — we never persist a value the runtime would
// ignore at exec time.
skills_local:
skillsLocalApplies && skillsLocal === "ignore" ? "ignore" : undefined,
};
if (template) {
// Duplicate path: forward the hidden config fields the source
@@ -385,14 +359,6 @@ export function CreateAgentDialog({
selectedIds={selectedSkillIds}
onChange={setSelectedSkillIds}
/>
{skillsLocalApplies && (
<SkillsLocalToggle
value={skillsLocal}
onChange={setSkillsLocal}
hintScope="create"
/>
)}
</div>
</div>
@@ -1,67 +0,0 @@
"use client";
import type { AgentSkillsLocal } from "@multica/core/types";
import { Label } from "@multica/ui/components/ui/label";
import { Switch } from "@multica/ui/components/ui/switch";
import { useT } from "../../i18n";
// SkillsLocalToggle renders the per-agent "merge host-machine skills" switch
// shared between the Create dialog (uncontrolled-by-network, only emits the
// new value) and the edit tab (which immediately persists). The component
// stays headless about save behavior — the parent decides what to do on
// change. Section labels follow the create dialog `skills_section` keys so
// both surfaces share identical copy.
export function SkillsLocalToggle({
value,
onChange,
disabled,
hintScope = "create",
}: {
value: AgentSkillsLocal;
onChange: (next: AgentSkillsLocal) => void;
disabled?: boolean;
/**
* Picks the i18n namespace for the secondary hint copy. The create dialog
* lives under `create_dialog.skills_section.local_*` while the edit tab
* uses `tab_body.skills.local_*` — wording is intentionally identical
* today but kept in two places so each surface can evolve independently.
*/
hintScope?: "create" | "tab";
}) {
const { t } = useT("agents");
const checked = value === "merge";
const label =
hintScope === "create"
? t(($) => $.create_dialog.skills_section.local_label)
: t(($) => $.tab_body.skills.local_label);
const hint = checked
? hintScope === "create"
? t(($) => $.create_dialog.skills_section.local_hint_on)
: t(($) => $.tab_body.skills.local_hint_on)
: hintScope === "create"
? t(($) => $.create_dialog.skills_section.local_hint_off)
: t(($) => $.tab_body.skills.local_hint_off);
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<Label
htmlFor="skills-local-toggle"
className="cursor-pointer text-sm font-medium leading-tight"
>
{label}
</Label>
<Switch
id="skills-local-toggle"
checked={checked}
onCheckedChange={(next: boolean) => onChange(next ? "merge" : "ignore")}
disabled={disabled}
/>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
{hint}
</p>
</div>
);
}
@@ -3,7 +3,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type { Agent, AgentRuntime } from "@multica/core/types";
import type { Agent } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
@@ -11,7 +11,6 @@ import enAgents from "../../../locales/en/agents.json";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
const mockListSkills = vi.hoisted(() => vi.fn());
const mockUpdateAgent = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
@@ -21,7 +20,6 @@ vi.mock("@multica/core/api", () => ({
api: {
listSkills: (...args: unknown[]) => mockListSkills(...args),
setAgentSkills: vi.fn(),
updateAgent: (...args: unknown[]) => mockUpdateAgent(...args),
},
}));
@@ -57,31 +55,7 @@ const agent: Agent = {
archived_by: null,
};
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
return {
id: "runtime-1",
workspace_id: "ws-1",
daemon_id: null,
name: "Runtime",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: null,
visibility: "public",
last_seen_at: null,
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
...overrides,
};
}
function renderSkillsTab(
overrides: Partial<Agent> = {},
runtime: AgentRuntime | null = makeRuntime(),
) {
function renderSkillsTab() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -93,7 +67,7 @@ function renderSkillsTab(
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<SkillsTab agent={{ ...agent, ...overrides }} runtime={runtime} />
<SkillsTab agent={agent} />
</QueryClientProvider>
</I18nProvider>,
);
@@ -103,7 +77,6 @@ describe("SkillsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListSkills.mockResolvedValue([]);
mockUpdateAgent.mockResolvedValue({});
});
it("does not render the inline Local Runtime Skills section even for local-runtime agents", async () => {
@@ -115,13 +88,9 @@ describe("SkillsTab", () => {
renderSkillsTab();
// Top informational callout should still render; that's how we know
// the tab body itself rendered (not stuck in a loading state). The
// toggle hint also includes the same phrase, so anchor on the intro
// copy specifically.
// the tab body itself rendered (not stuck in a loading state).
expect(
await screen.findByText(
/Workspace skills assigned to this agent/i,
),
await screen.findByText(/Local runtime skills are always available/i),
).toBeInTheDocument();
// The removed section's heading and its trigger button must be gone.
@@ -129,70 +98,13 @@ describe("SkillsTab", () => {
expect(
screen.queryByRole("button", { name: /Import to Workspace/i }),
).not.toBeInTheDocument();
});
it("renders the local-skill toggle ON by default (merge — preserves inherit-from-machine behavior)", async () => {
// Bohan's MUL-2603 product decision: keep the legacy "merge" default so
// personal workflows that rely on locally installed Claude Skills keep
// working unchanged. Owners flip the toggle off ("ignore") when they
// explicitly need to harden a shared agent.
renderSkillsTab();
const toggle = await screen.findByRole("switch", {
name: /Allow locally installed skills/i,
});
// Base UI Switch reflects state via data-state; aria-checked is the
// accessible read.
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("reflects ignore mode when the agent opted into isolation", async () => {
renderSkillsTab({ skills_local: "ignore" });
const toggle = await screen.findByRole("switch", {
name: /Allow locally installed skills/i,
});
expect(toggle.getAttribute("aria-checked")).toBe("false");
});
it("renders the toggle for Codex runtimes too", async () => {
renderSkillsTab({}, makeRuntime({ provider: "codex" }));
expect(
await screen.findByRole("switch", {
name: /Allow locally installed skills/i,
}),
).toBeInTheDocument();
});
it("hides the toggle for runtimes that don't honour skills_local", async () => {
// Only Claude and Codex runtimes actually enforce skills_local at exec
// time today (MUL-2603); on every other provider the field would be
// stored but ignored, so the toggle is suppressed to avoid implying
// behaviour the runtime won't deliver.
renderSkillsTab({}, makeRuntime({ provider: "copilot" }));
// The rest of the tab still renders.
expect(
await screen.findByText(/Workspace skills assigned to this agent/i),
).toBeInTheDocument();
expect(
screen.queryByRole("switch", {
name: /Allow locally installed skills/i,
}),
).not.toBeInTheDocument();
});
it("hides the toggle when the runtime cannot be resolved", async () => {
renderSkillsTab({}, null);
expect(
await screen.findByText(/Workspace skills assigned to this agent/i),
).toBeInTheDocument();
expect(
screen.queryByRole("switch", {
name: /Allow locally installed skills/i,
}),
).not.toBeInTheDocument();
// No runtime list / local-skills query should be wired up either —
// we removed @multica/core/runtimes from this file's imports.
// Surface it via behaviour: the `agent` here has runtime_id but the
// tab must not invoke any runtime-list mock to render. (Both are
// already deleted from the mock setup above; this assertion is
// implicit — the test file would fail to import if the component
// still referenced runtimeListOptions / runtimeLocalSkillsOptions.)
});
});
@@ -1,16 +1,11 @@
"use client";
import { useState } from "react";
import { FileText, Plus, Trash2 } from "lucide-react";
import { FileText, Info, Plus, Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
Agent,
AgentRuntime,
AgentSkillsLocal,
} from "@multica/core/types";
import type { Agent } from "@multica/core/types";
import { api } from "@multica/core/api";
import { isSkillsLocalSupportedProvider } from "@multica/core/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import {
skillListOptions,
@@ -18,33 +13,16 @@ import {
} from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import { SkillAddDialog } from "../skill-add-dialog";
import { SkillsLocalToggle } from "../skills-local-toggle";
import { useT } from "../../../i18n";
export function SkillsTab({
agent,
runtime,
}: {
agent: Agent;
/**
* The runtime this agent is bound to, if known. Used to decide whether
* the host-skill merge toggle is meaningful for this agent — only Claude
* and Codex runtimes honour `skills_local` today (MUL-2603). When the
* runtime is missing (parent couldn't resolve it, e.g. it was deleted)
* the toggle is hidden — there is no usable provider context to gate on.
*/
runtime?: AgentRuntime | null;
}) {
const { t } = useT("agents");
const qc = useQueryClient();
const wsId = useWorkspaceId();
// Default missing values to "merge". Older backends omit the field
// entirely; the documented platform default wins on drift (see
// AgentSkillsLocal — "ignore" is the explicit hardening opt-in).
const currentSkillsLocal: AgentSkillsLocal =
agent.skills_local === "ignore" ? "ignore" : "merge";
const [skillsLocalSaving, setSkillsLocalSaving] = useState(false);
const skillsLocalApplies = isSkillsLocalSupportedProvider(runtime?.provider);
// Same query the SkillAddDialog uses (TanStack Query dedupes by key, so
// this isn't an extra request) — used here only to grey out the "Add
// skill" button when the workspace has zero skills total. When skills
@@ -56,24 +34,6 @@ export function SkillsTab({
const [removing, setRemoving] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const handleSkillsLocalChange = async (next: AgentSkillsLocal) => {
if (next === currentSkillsLocal || skillsLocalSaving) return;
setSkillsLocalSaving(true);
try {
await api.updateAgent(agent.id, { skills_local: next });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success(t(($) => $.tab_body.skills.local_saved_toast));
} catch (e) {
toast.error(
e instanceof Error
? e.message
: t(($) => $.tab_body.skills.local_save_failed_toast),
);
} finally {
setSkillsLocalSaving(false);
}
};
const handleRemove = async (skillId: string) => {
setRemoving(true);
try {
@@ -91,7 +51,7 @@ export function SkillsTab({
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.intro)}
</p>
@@ -107,15 +67,12 @@ export function SkillsTab({
</Button>
</div>
{skillsLocalApplies && (
<SkillsLocalToggle
value={currentSkillsLocal}
onChange={handleSkillsLocalChange}
disabled={skillsLocalSaving}
hintScope="tab"
/>
)}
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-info" />
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.import_hint)}
</p>
</div>
{agent.skills.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
+3 -10
View File
@@ -242,10 +242,7 @@
"list_loading": "Loading…",
"list_empty_default": "No skills available.",
"list_no_match": "No skills match.",
"search_placeholder": "Search skills…",
"local_label": "Allow locally installed skills",
"local_hint_off": "Off — the agent ignores the host machine's user-global skill directory (~/.claude/skills/, ~/.codex/skills/). Recommended for shared agents — a broken local skill on one operator's machine cannot crash this agent. This control is shown only for Claude Code and Codex.",
"local_hint_on": "On (default) — the host machine's user-global skill directory is merged alongside workspace skills. This control is shown only for Claude Code and Codex."
"search_placeholder": "Search skills…"
},
"avatar": {
"change_aria": "Change avatar",
@@ -298,13 +295,9 @@
"save_failed_toast": "Failed to save custom arguments"
},
"skills": {
"intro": "Workspace skills assigned to this agent. Every runtime loads them.",
"intro": "Workspace skills assigned to this agent. Local runtime skills are always available automatically.",
"add_action": "Add skill",
"local_label": "Allow locally installed skills",
"local_hint_off": "Off — the agent ignores the host machine's user-global skill directory (~/.claude/skills/, ~/.codex/skills/). Recommended for shared agents — a broken local skill on one operator's machine cannot crash this agent. This control is shown only for Claude Code and Codex.",
"local_hint_on": "On (default) — the host machine's user-global skill directory is merged alongside workspace skills. This control is shown only for Claude Code and Codex.",
"local_saved_toast": "Skill merge setting saved",
"local_save_failed_toast": "Failed to save skill merge setting",
"import_hint": "Importing creates a workspace copy that your team can edit and reuse.",
"empty_title": "No skills assigned",
"empty_hint": "Add workspace skills to share team knowledge with this agent.",
"remove_failed_toast": "Failed to remove skill",
+3 -10
View File
@@ -237,10 +237,7 @@
"list_loading": "加载中…",
"list_empty_default": "暂无 skill。",
"list_no_match": "没有匹配的 skill。",
"search_placeholder": "搜索 skill…",
"local_label": "允许使用本机已安装的 Skill",
"local_hint_off": "关闭 —— 智能体会忽略本机用户级 skill 目录(~/.claude/skills/、~/.codex/skills/)。推荐用于共享智能体 —— 团队成员机器上有问题的本机 skill 不会拖崩这个智能体。该开关仅对 Claude Code 与 Codex 显示。",
"local_hint_on": "开启(默认)—— 运行时会同时合并本机用户级 skill 目录。该开关仅对 Claude Code 与 Codex 显示。"
"search_placeholder": "搜索 skill…"
},
"avatar": {
"change_aria": "更换头像",
@@ -292,13 +289,9 @@
"save_failed_toast": "保存自定义参数失败"
},
"skills": {
"intro": "分配给该智能体的工作区 skill。所有运行时都会加载。",
"intro": "分配给该智能体的工作区 skill。本地运行时 skill 会自动可用。",
"add_action": "添加 skill",
"local_label": "允许使用本机已安装的 Skill",
"local_hint_off": "关闭 —— 智能体会忽略本机用户级 skill 目录(~/.claude/skills/、~/.codex/skills/)。推荐用于共享智能体 —— 团队成员机器上有问题的本机 skill 不会拖崩这个智能体。该开关仅对 Claude Code 与 Codex 显示。",
"local_hint_on": "开启(默认)—— 运行时会同时合并本机用户级 skill 目录。该开关仅对 Claude Code 与 Codex 显示。",
"local_saved_toast": "已保存 Skill 合并设置",
"local_save_failed_toast": "保存 Skill 合并设置失败",
"import_hint": "导入会在工作区创建一份副本,团队可以编辑和复用。",
"empty_title": "尚未分配 skill",
"empty_hint": "添加工作区 skill,把团队知识共享给这个智能体。",
"remove_failed_toast": "移除 skill 失败",
-11
View File
@@ -2285,17 +2285,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
if provider == "openclaw" {
openclawBin = entry.Path
}
envSkillsLocal := ""
if task.Agent != nil {
envSkillsLocal = task.Agent.SkillsLocal
}
if task.PriorWorkDir != "" {
env = execenv.Reuse(execenv.ReuseParams{
WorkDir: task.PriorWorkDir,
Provider: provider,
CodexVersion: codexVersion,
OpenclawBin: openclawBin,
SkillsLocal: envSkillsLocal,
Task: taskCtx,
}, d.logger)
}
@@ -2309,7 +2304,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
Provider: provider,
CodexVersion: codexVersion,
OpenclawBin: openclawBin,
SkillsLocal: envSkillsLocal,
Task: taskCtx,
}, d.logger)
if err != nil {
@@ -2496,10 +2490,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
thinkingLevel = ""
}
}
skillsLocal := ""
if task.Agent != nil {
skillsLocal = task.Agent.SkillsLocal
}
execOpts := agent.ExecOptions{
Cwd: env.WorkDir,
Model: model,
@@ -2510,7 +2500,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
CustomArgs: customArgs,
McpConfig: mcpConfig,
ThinkingLevel: thinkingLevel,
SkillsLocal: skillsLocal,
}
// Some providers do not reliably load the per-task runtime config files we
// write into the task workdir:
+20 -41
View File
@@ -32,21 +32,14 @@ type ProjectResourceForEnv struct {
// PrepareParams holds all inputs needed to set up an execution environment.
type PrepareParams struct {
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
WorkspaceID string // workspace UUID — tasks are grouped under this
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
Provider string // agent provider (determines runtime config and skill injection paths)
CodexVersion string // detected Codex CLI version (only used when Provider == "codex")
OpenclawBin string // resolved openclaw CLI path (only used when Provider == "openclaw"); empty = look up on PATH
// SkillsLocal mirrors agent.SkillsLocal. "ignore" instructs runtime
// setup steps that the daemon controls (currently Codex's user-skill
// seed) to skip injecting the host machine's user-global skills into
// the per-task environment. Any other value — including "merge" and
// the empty string from older servers / clients — preserves the
// inherit-from-host behavior.
SkillsLocal string
Task TaskContextForEnv // context data for writing files
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
WorkspaceID string // workspace UUID — tasks are grouped under this
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
Provider string // agent provider (determines runtime config and skill injection paths)
CodexVersion string // detected Codex CLI version (only used when Provider == "codex")
OpenclawBin string // resolved openclaw CLI path (only used when Provider == "openclaw"); empty = look up on PATH
Task TaskContextForEnv // context data for writing files
}
// TaskContextForEnv is the subset of task context used for writing context files.
@@ -182,7 +175,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion}, logger); err != nil {
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
}
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, params.SkillsLocal, logger); err != nil {
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, logger); err != nil {
return nil, fmt.Errorf("execenv: hydrate codex skills: %w", err)
}
env.CodexHome = codexHome
@@ -213,14 +206,9 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
type ReuseParams struct {
WorkDir string
Provider string
CodexVersion string // only used when Provider == "codex"
OpenclawBin string // only used when Provider == "openclaw"; empty = PATH lookup
// SkillsLocal mirrors PrepareParams.SkillsLocal. Reuse must respect
// the same gate so a workdir resurrected on a later task picks up the
// current agent setting (the user may have flipped the toggle between
// runs).
SkillsLocal string
Task TaskContextForEnv // refreshed context files / skills
CodexVersion string // only used when Provider == "codex"
OpenclawBin string // only used when Provider == "openclaw"; empty = PATH lookup
Task TaskContextForEnv // refreshed context files / skills
}
// Reuse wraps an existing workdir into an Environment and refreshes context files.
@@ -250,7 +238,7 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment {
logger.Warn("execenv: refresh codex-home failed", "error", err)
} else {
env.CodexHome = codexHome
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, params.SkillsLocal, logger); err != nil {
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, logger); err != nil {
logger.Warn("execenv: refresh codex skills failed", "error", err)
}
}
@@ -291,26 +279,17 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment {
// run — its old contents would otherwise remain visible to the codex
// CLI.
//
// When skillsLocal == "ignore" the user-skill seed is skipped, so the
// per-task CODEX_HOME exposes only workspace skills to the codex CLI. This
// is the Codex-side enforcement of the per-agent `skills_local` toggle —
// the Claude backend enforces the same contract by mirroring `~/.claude/`
// minus `skills/` (see server/pkg/agent/claude.go).
//
// Codex is the only runtime besides Claude where the daemon actively
// manages user-skill discovery: Codex via CODEX_HOME and the seed step
// here, Claude via CLAUDE_CONFIG_DIR scratch mirroring. Other runtimes
// leave HOME untouched and discover user-level skills natively (see
// context.go for the workdir-local paths they use for workspace skills),
// so `skills_local` is a no-op for them today.
func hydrateCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv, skillsLocal string, logger *slog.Logger) error {
// Codex is the only runtime that needs this two-stage hydration because the
// daemon sets CODEX_HOME to a per-task directory, isolating the CLI from the
// user's real ~/.codex/. Other runtimes leave HOME untouched and discover
// user-level skills natively (see context.go for the workdir-local paths
// they use for workspace skills).
func hydrateCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv, logger *slog.Logger) error {
skillsDir := filepath.Join(codexHome, "skills")
if err := os.RemoveAll(skillsDir); err != nil {
return fmt.Errorf("clear codex skills dir: %w", err)
}
if skillsLocal == "ignore" {
logger.Info("execenv: codex user-skill seed skipped (skills_local=ignore)")
} else if err := seedUserCodexSkills(codexHome, workspaceSkills, logger); err != nil {
if err := seedUserCodexSkills(codexHome, workspaceSkills, logger); err != nil {
logger.Warn("execenv: seed user codex skills failed", "error", err)
}
if len(workspaceSkills) == 0 {
@@ -2650,145 +2650,6 @@ func TestPrepareCodexResolvesUserSkillSymlinks(t *testing.T) {
}
}
// TestPrepareCodexSkillsLocalIgnoreSkipsUserSeed covers the per-agent
// `skills_local=ignore` contract on the Codex side (MUL-2603): when the
// agent opts out of host-machine skills, the daemon must not seed
// `~/.codex/skills/` into the per-task CODEX_HOME, so a broken local skill
// cannot crash a shared Codex agent. The default (`merge`) preserves the
// pre-existing seed-from-shared behavior — covered by
// TestPrepareCodexSeedsUserSkills above.
func TestPrepareCodexSkillsLocalIgnoreSkipsUserSeed(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "summarize"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "summarize", "SKILL.md"), []byte("user summarize"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-skills-local-ignore",
TaskID: "c1d2e3f4-a5b6-7890-cdef-123456789012",
AgentName: "Codex Agent",
Provider: "codex",
SkillsLocal: "ignore",
Task: TaskContextForEnv{
IssueID: "codex-skills-local-ignore-test",
AgentSkills: []SkillContextForEnv{
{Name: "Writing", Content: "workspace writing"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "summarize")); !os.IsNotExist(err) {
t.Errorf("user skill seeded despite skills_local=ignore: err=%v", err)
}
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "writing", "SKILL.md")); err != nil {
t.Errorf("workspace skill not written under skills_local=ignore: %v", err)
}
}
// TestPrepareCodexSkillsLocalMergeSeedsUserSkills locks in the default
// `merge` semantics on the Codex side: anything other than the literal
// "ignore" (including empty from older servers) preserves the seed-from-
// shared behavior so existing personal workflows continue working.
func TestPrepareCodexSkillsLocalMergeSeedsUserSkills(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "translate"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "translate", "SKILL.md"), []byte("user translate"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-skills-local-merge",
TaskID: "d2e3f4a5-b6c7-8901-cdef-234567890123",
AgentName: "Codex Agent",
Provider: "codex",
SkillsLocal: "merge",
Task: TaskContextForEnv{IssueID: "codex-skills-local-merge-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
data, err := os.ReadFile(filepath.Join(env.CodexHome, "skills", "translate", "SKILL.md"))
if err != nil {
t.Fatalf("user skill not seeded under skills_local=merge: %v", err)
}
if string(data) != "user translate" {
t.Errorf("seeded user SKILL.md = %q, want %q", data, "user translate")
}
}
// TestReuseCodexSkillsLocalIgnoreSkipsUserSeed mirrors the Prepare path on
// the Reuse branch: if a user flips the agent toggle to `ignore` between
// runs, the next task on the reused workdir must drop the previously-seeded
// user skill rather than leak it through.
func TestReuseCodexSkillsLocalIgnoreSkipsUserSeed(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "summarize"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "summarize", "SKILL.md"), []byte("user summarize"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-reuse-toggle",
TaskID: "e3f4a5b6-c7d8-9012-cdef-345678901234",
AgentName: "Codex Agent",
Provider: "codex",
SkillsLocal: "merge",
Task: TaskContextForEnv{IssueID: "codex-reuse-toggle-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "summarize", "SKILL.md")); err != nil {
t.Fatalf("Prepare(merge) did not seed user skill: %v", err)
}
reused := Reuse(ReuseParams{
WorkDir: env.WorkDir,
Provider: "codex",
SkillsLocal: "ignore",
Task: TaskContextForEnv{IssueID: "codex-reuse-toggle-test"},
}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
if _, err := os.Stat(filepath.Join(reused.CodexHome, "skills", "summarize")); !os.IsNotExist(err) {
t.Errorf("user skill survived Reuse(skills_local=ignore): err=%v", err)
}
}
// TestReuseSeedsUserSkillUpdates ensures that user-skill edits between two
// runs of the same task (the Reuse path) propagate into the per-task home.
func TestReuseSeedsUserSkillUpdates(t *testing.T) {
-12
View File
@@ -105,18 +105,6 @@ type AgentData struct {
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"`
// SkillsLocal controls whether the runtime merges the host machine's
// user-global skill directory into the agent. "merge" (default)
// preserves the inherit-from-machine behavior; "ignore" hides the
// directory from the runtime so a broken local skill cannot crash a
// shared agent (#3052). Old servers that predate the column omit this
// field entirely; the daemon treats empty as "merge". Currently
// honoured by Claude (via CLAUDE_CONFIG_DIR scratch mirroring in
// server/pkg/agent/claude.go) and Codex (via the user-skill seed gate
// in server/internal/daemon/execenv/execenv.go); other runtimes leave
// HOME untouched and natively discover user-level skills, so the
// toggle is a no-op for them.
SkillsLocal string `json:"skills_local,omitempty"`
}
// SkillData represents a structured skill for task execution.
+2 -72
View File
@@ -56,15 +56,8 @@ type AgentResponse struct {
// ThinkingLevel is the runtime-native reasoning/effort token persisted
// for this agent (empty = use runtime default). The picker is per-runtime
// per-model; the API never normalizes across providers. See MUL-2339.
ThinkingLevel string `json:"thinking_level"`
// SkillsLocal controls whether the runtime merges the host machine's
// user-global skill directory (e.g. Claude's `~/.claude/skills/`) into
// the agent's skill set. "merge" (default) preserves the pre-existing
// inherit-from-machine behavior; "ignore" isolates the runtime so a
// broken local skill cannot crash a shared agent (#3052). Workspace
// skills (`{workDir}/.claude/skills/`) are always loaded regardless.
SkillsLocal string `json:"skills_local"`
OwnerID *string `json:"owner_id"`
ThinkingLevel string `json:"thinking_level"`
OwnerID *string `json:"owner_id"`
Skills []AgentSkillSummary `json:"skills"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
@@ -129,7 +122,6 @@ func agentToResponse(a db.Agent) AgentResponse {
MaxConcurrentTasks: a.MaxConcurrentTasks,
Model: a.Model.String,
ThinkingLevel: a.ThinkingLevel.String,
SkillsLocal: normalizeSkillsLocal(a.SkillsLocal),
OwnerID: uuidToPtr(a.OwnerID),
Skills: []AgentSkillSummary{},
CreatedAt: timestampToString(a.CreatedAt),
@@ -253,11 +245,6 @@ type TaskAgentData struct {
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"`
// SkillsLocal mirrors the agent column of the same name. Daemon reads
// it to decide whether to isolate the Claude runtime from the host's
// user-global `~/.claude/skills/`. Empty string is treated as the
// platform default ("merge") by the daemon — see normalizeSkillsLocal.
SkillsLocal string `json:"skills_local,omitempty"`
}
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
@@ -475,11 +462,6 @@ type CreateAgentRequest struct {
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
Model string `json:"model"`
ThinkingLevel string `json:"thinking_level"`
// SkillsLocal opts the agent into ("merge") or out of ("ignore") the
// runtime's user-global skill discovery. Empty / missing defaults to
// "merge". Validated in CreateAgent / UpdateAgent against the
// recognised set; anything else returns 400.
SkillsLocal string `json:"skills_local"`
// Template records which template slug was used to seed this agent
// (e.g. "coding" / "planning" / "writing" / "assistant"). Empty when
// the caller didn't come from a template picker — the `agent_created`
@@ -579,21 +561,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
return
}
// Request validation is strict: a missing field defaults to "merge" (the
// pre-MUL-2603 inherit-from-machine behavior), but a present-but-bogus
// value must 400 rather than silently normalize. The drift-safe normalize
// is only for on-read coercion of stored values (normalizeSkillsLocal) —
// applying it to request input would mask client typos as a successful
// create.
skillsLocal := "merge"
if req.SkillsLocal != "" {
if !isValidSkillsLocal(req.SkillsLocal) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("skills_local %q is invalid (expected \"ignore\" or \"merge\")", req.SkillsLocal))
return
}
skillsLocal = req.SkillsLocal
}
// Probe workspace agent count BEFORE the insert so the funnel has a
// clean "first agent ever in this workspace" signal — Step 4 of
// onboarding always lands in this branch. A non-fatal read: if the
@@ -641,7 +608,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
McpConfig: mc,
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
ThinkingLevel: pgtype.Text{String: req.ThinkingLevel, Valid: req.ThinkingLevel != ""},
SkillsLocal: skillsLocal,
})
if err != nil {
// Unique constraint on (workspace_id, name) — return a clear conflict error
@@ -709,31 +675,6 @@ type UpdateAgentRequest struct {
// Distinguishing those modes is why this is a pointer; the raw-fields
// map captured at decode time tells us whether the key was sent.
ThinkingLevel *string `json:"thinking_level"`
// SkillsLocal: nil means "no change"; non-nil overrides the stored
// value after validation. We use a pointer (rather than the empty
// string sentinel ThinkingLevel uses) because there is no "clear"
// state — the column has a NOT NULL default and the toggle is binary.
SkillsLocal *string `json:"skills_local"`
}
// normalizeSkillsLocal coerces empty / unknown stored values to the platform
// default "merge" — the pre-MUL-2603 inherit-from-machine behavior. Older
// rows written before the column existed get a NOT NULL default at migration
// time, but the helper still guards against any future schema drift or
// hand-edited rows so we never surface an in-between state to clients or the
// daemon.
func normalizeSkillsLocal(stored string) string {
if stored == "ignore" {
return "ignore"
}
return "merge"
}
// isValidSkillsLocal mirrors the CHECK constraint on agent.skills_local.
// The DB would reject a write with a 23514 violation anyway, but pre-flight
// validation lets the API return a clean 400 with the offending value.
func isValidSkillsLocal(v string) bool {
return v == "ignore" || v == "merge"
}
// workspaceAlwaysRedactSecrets reports whether the workspace has opted
@@ -935,17 +876,6 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
if req.Model != nil {
params.Model = pgtype.Text{String: *req.Model, Valid: true}
}
if req.SkillsLocal != nil {
// PATCH is strictly opt-in: nil means no change. A non-nil value must
// validate as-is — normalizing here would let garbage silently land as
// "merge" and hide a client bug. The drift-safe coercion lives in
// normalizeSkillsLocal for on-read use only.
if !isValidSkillsLocal(*req.SkillsLocal) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("skills_local %q is invalid (expected \"ignore\" or \"merge\")", *req.SkillsLocal))
return
}
params.SkillsLocal = pgtype.Text{String: *req.SkillsLocal, Valid: true}
}
// thinking_level handling (MUL-2339). Tri-state semantics:
// - field omitted → leave column alone (COALESCE narg), but if a
@@ -1,182 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestCreateAgent_SkillsLocal_Validation pins the contract for the
// per-agent host-skill merge switch added with MUL-2603 / GitHub #3052:
//
// - Field omitted → platform default "merge" is persisted (preserves the
// pre-MUL-2603 inherit-from-machine behavior so existing personal
// workflows that rely on locally installed skills keep working).
// - "ignore" / "merge" → accepted and round-tripped.
// - Any other value → 400 BadRequest. The DB CHECK would catch it as a
// 500 fallback, but we want a clean error for clients.
func TestCreateAgent_SkillsLocal_Validation(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
claudeRuntimeID := createClaudeProviderRuntime(t)
t.Cleanup(func() {
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name LIKE 'skills-local-test-%'`,
testWorkspaceID,
)
})
t.Run("omitted defaults to merge", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-test-default",
"runtime_id": claudeRuntimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
if w.Code != http.StatusCreated {
t.Fatalf("default skills_local: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "merge" {
t.Errorf("expected skills_local=merge (platform default), got %v", resp["skills_local"])
}
})
t.Run("explicit ignore round-trips", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-test-ignore",
"runtime_id": claudeRuntimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
"skills_local": "ignore",
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
if w.Code != http.StatusCreated {
t.Fatalf("skills_local=ignore: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "ignore" {
t.Errorf("expected skills_local=ignore, got %v", resp["skills_local"])
}
})
t.Run("garbage value rejected with 400", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-test-garbage",
"runtime_id": claudeRuntimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
"skills_local": "yes-please",
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
if w.Code != http.StatusBadRequest {
t.Fatalf("garbage skills_local: expected 400, got %d: %s", w.Code, w.Body.String())
}
})
}
// TestUpdateAgent_SkillsLocal_Tristate covers PATCH semantics: nil
// pointer means "no change", non-nil overrides after validation. Unlike
// thinking_level there is no "clear" mode — the column is NOT NULL.
func TestUpdateAgent_SkillsLocal_Tristate(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
claudeRuntimeID := createClaudeProviderRuntime(t)
agentID := createAgentOnRuntime(t, "skills-local-update-test", claudeRuntimeID, "")
// Demote the seed row to "ignore" so we can verify both omit (preserve
// ignore) and explicit-flip-back (set merge) paths.
if _, err := testPool.Exec(ctx, `UPDATE agent SET skills_local = 'ignore' WHERE id = $1`, agentID); err != nil {
t.Fatalf("seed skills_local=ignore: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent WHERE id = $1`, agentID)
})
t.Run("omitted preserves existing value", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-update-test-renamed",
}
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPatch, "/api/agents/"+agentID, body), "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("name-only update: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "ignore" {
t.Errorf("name-only update silently changed skills_local: got %v, want ignore", resp["skills_local"])
}
})
t.Run("explicit merge flips back", func(t *testing.T) {
body := map[string]any{
"skills_local": "merge",
}
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPatch, "/api/agents/"+agentID, body), "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update skills_local=merge: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "merge" {
t.Errorf("expected skills_local=merge after update, got %v", resp["skills_local"])
}
})
t.Run("garbage value is 400 not silent default", func(t *testing.T) {
body := map[string]any{
"skills_local": "all-of-them",
}
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPatch, "/api/agents/"+agentID, body), "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("garbage skills_local: expected 400, got %d: %s", w.Code, w.Body.String())
}
})
}
// TestNormalizeSkillsLocal_DriftStaysSafe is a unit-level safety net for
// the on-read coercion: if a row gets written with anything other than
// the two recognised values (hand edits, future schema drift, older
// daemons sending up garbage), the API must surface "merge" — the
// platform default — never leak an indeterminate value to clients or the
// daemon. Only the exact literal "ignore" maps to ignore; anything else
// coerces to the documented default.
func TestNormalizeSkillsLocal_DriftStaysSafe(t *testing.T) {
cases := []struct {
stored string
want string
}{
{"", "merge"},
{"ignore", "ignore"},
{"merge", "merge"},
{"IGNORE", "merge"}, // case-sensitive — drift falls back to platform default
{"yes", "merge"},
{"true", "merge"},
}
for _, c := range cases {
if got := normalizeSkillsLocal(c.stored); got != c.want {
t.Errorf("normalizeSkillsLocal(%q) = %q, want %q", c.stored, got, c.want)
}
}
}
@@ -446,10 +446,6 @@ func (h *Handler) CreateAgentFromTemplate(w http.ResponseWriter, r *http.Request
CustomArgs: ca,
McpConfig: nil,
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
// skills_local has a NOT NULL CHECK ('ignore' | 'merge'); the Go zero
// value "" would fail the constraint instead of falling back to the
// column default. Match the public CreateAgent default explicitly.
SkillsLocal: "merge",
})
if err != nil {
// Mirror handler/agent.go:CreateAgent: when the duplicate is the
-1
View File
@@ -1124,7 +1124,6 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
McpConfig: mcpConfig,
Model: agent.Model.String,
ThinkingLevel: agent.ThinkingLevel.String,
SkillsLocal: normalizeSkillsLocal(agent.SkillsLocal),
}
}
@@ -227,10 +227,6 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ
CustomArgs: []byte("[]"),
McpConfig: nil,
Model: pgtype.Text{},
// skills_local has a NOT NULL CHECK ('ignore' | 'merge'); the Go
// zero value "" would trip the constraint rather than fall back to
// the column default. Match the public CreateAgent default.
SkillsLocal: "merge",
})
if err != nil {
slog.Warn("bootstrap onboarding (shim): create assistant failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
@@ -1 +0,0 @@
ALTER TABLE agent DROP COLUMN skills_local;
@@ -1,12 +0,0 @@
-- Per-agent switch controlling whether the Claude runtime merges the host
-- machine's `~/.claude/skills/` into the agent's skill set. 'merge' (default)
-- preserves the pre-existing inherit-from-machine behavior so existing
-- workflows that rely on locally installed skills keep working. 'ignore'
-- isolates the runtime so a broken local skill on one operator's machine
-- cannot silently crash a shared agent (GitHub #3052 — Claude exits before
-- reading stdin, leaving the daemon with "broken pipe"). Workspace skills
-- (`{workDir}/.claude/skills/`) are always loaded — the toggle only governs
-- the user-global directory.
ALTER TABLE agent
ADD COLUMN skills_local TEXT NOT NULL DEFAULT 'merge'
CHECK (skills_local IN ('ignore', 'merge'));
@@ -0,0 +1,3 @@
-- Intentionally empty. The local skill toggle was reverted before release, so
-- rolling this cleanup back should only remove the schema_migrations row; it
-- should not recreate an unused column or invent values for it.
@@ -0,0 +1,4 @@
-- Clean up the unreleased per-agent local skill toggle. Fresh/prod databases
-- that never applied 108_agent_skills_local are unchanged, while dev/staging
-- databases that tested it converge back to the reverted schema.
ALTER TABLE agent DROP COLUMN IF EXISTS skills_local;
-13
View File
@@ -44,19 +44,6 @@ type ExecOptions struct {
// field rather than fail (so MUL-2339 can grow runtime support
// incrementally without breaking unrelated agents).
ThinkingLevel string
// SkillsLocal opts the runtime into ("merge") or out of ("ignore") the
// host machine's user-global skill directory. Honoured by the Claude
// backend in this package — "merge" (platform default; anything other
// than "ignore") preserves the inherit-from-machine behavior so the
// CLI walks `~/.claude/` directly, and "ignore" redirects
// CLAUDE_CONFIG_DIR to an isolated per-task scratch dir so the CLI
// never reads `~/.claude/skills/` (used to harden shared agents
// against broken host skills — GitHub #3052). Codex enforces the same
// contract one layer up in the daemon: `execenv.hydrateCodexSkills`
// skips the `~/.codex/skills/` seed when this value is "ignore", so
// the per-task CODEX_HOME exposes only workspace skills. Other
// backends ignore this option today (no-op).
SkillsLocal string
}
// Session represents a running agent execution.
+2 -455
View File
@@ -10,7 +10,6 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
@@ -67,57 +66,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
// Skill isolation. The platform default is "merge" — the CLI walks
// `~/.claude/` directly, including its `skills/`, so existing personal
// workflows that rely on locally installed Claude skills keep working
// out of the box. The agent owner can opt into "ignore" when a shared
// agent must be hardened against a broken local skill on one operator's
// machine, which otherwise crashes the CLI before it reads stdin
// (GitHub #3052: silent "broken pipe" exits).
//
// In "ignore" mode we point CLAUDE_CONFIG_DIR at a per-run scratch dir
// that mirrors `~/.claude/` as symlinks — except for `skills/`, which
// is omitted so the CLI cannot discover the host user's
// `~/.claude/skills/`. Everything else under `~/.claude/` — including
// the Linux/Windows `.credentials.json` login token, `settings.json`,
// `agents/`, `commands/`, `plugins/`, etc. — is symlinked through so
// Claude authentication and global config keep working without an
// `ANTHROPIC_API_KEY`. Workspace skills (`{cwd}/.claude/skills/`) load
// from cwd regardless and are not affected by either mode.
isolateClaudeConfig := opts.SkillsLocal == "ignore"
var claudeConfigDir string
var hostConfigDir string
var claudeConfigCleanup func()
if isolateClaudeConfig {
// Resolve the *effective* Claude config source before we strip
// CLAUDE_CONFIG_DIR from the child env in buildClaudeEnv.
// Precedence: agent custom_env > parent process env > `~/.claude`.
// Mirroring `~/.claude` blindly when the operator has explicitly
// pointed Claude at a different config dir (e.g. a managed install)
// would copy the wrong credentials into the scratch dir and break
// auth — see the second Must Fix in MUL-2603 review. The same
// value is threaded through to buildClaudeEnv so the macOS keychain
// passthrough can refuse to inject the default OAuth token into a
// custom-dir isolated child (the unsuffixed keychain entry does not
// match the suffixed entry a custom CLAUDE_CONFIG_DIR would resolve
// to, so reading it would inject a different account's token).
hostConfigDir = resolveHostClaudeConfigDir(b.cfg.Env)
dir, cleanup, err := newIsolatedClaudeConfigDir(opts.Cwd, hostConfigDir, b.cfg.Logger)
if err != nil {
cancel()
return nil, fmt.Errorf("isolate claude config dir: %w", err)
}
claudeConfigDir = dir
claudeConfigCleanup = cleanup
}
defer func() {
if claudeConfigCleanup != nil {
claudeConfigCleanup()
}
}()
cmd.Env = buildClaudeEnv(b.cfg.Env, claudeConfigDir, hostConfigDir, b.cfg.Logger)
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -162,14 +111,10 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
}
closeStdin()
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model, "skills_local", opts.SkillsLocal, "claude_config_dir", claudeConfigDir)
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
mcpFileCleanup = nil
// Transfer isolated-config cleanup the same way: the goroutine outlives
// this function, so it owns removal of the scratch dir.
isolatedConfigCleanup := claudeConfigCleanup
claudeConfigCleanup = nil
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
@@ -181,9 +126,6 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
if mcpConfigPath != "" {
defer os.Remove(mcpConfigPath)
}
if isolatedConfigCleanup != nil {
defer isolatedConfigCleanup()
}
startTime := time.Now()
var output strings.Builder
@@ -625,170 +567,6 @@ func buildEnv(extra map[string]string) []string {
return mergeEnv(os.Environ(), extra)
}
// buildClaudeEnv builds the env slice for the claude subprocess. When
// claudeConfigDir is non-empty (isolated-skills mode), it overrides
// CLAUDE_CONFIG_DIR for the child so the CLI looks at the per-task
// scratch dir instead of `~/.claude/`. We also strip the parent's
// CLAUDE_CONFIG_DIR so a daemon-host env var cannot accidentally win
// the override race. Callers in "merge" mode pass "" and behaviour
// matches the pre-MUL-2603 buildEnv path.
//
// hostConfigDir is the *effective* host Claude config source the scratch
// dir was mirrored from (see resolveHostClaudeConfigDir). It is used as
// the gate for the macOS keychain OAuth passthrough: only the default
// `$HOME/.claude` host source matches the unsuffixed
// `Claude Code-credentials` keychain entry, so for custom dirs we
// deliberately skip the passthrough rather than inject the daemon
// user's default account into a managed/custom-dir agent.
func buildClaudeEnv(extra map[string]string, claudeConfigDir, hostConfigDir string, logger *slog.Logger) []string {
return buildClaudeEnvWith(extra, claudeConfigDir, hostConfigDir, logger, os.UserHomeDir, readHostClaudeOAuthToken)
}
// buildClaudeEnvWith is the testable seam behind buildClaudeEnv. Tests
// inject a homeDir resolver and readOAuthToken closure so they can
// exercise the auth passthrough — including the default-vs-custom
// hostConfigDir gate — without touching the real macOS keychain or
// mutating $HOME.
func buildClaudeEnvWith(
extra map[string]string,
claudeConfigDir, hostConfigDir string,
logger *slog.Logger,
homeDir func() (string, error),
readOAuthToken func() (string, error),
) []string {
env := mergeEnv(os.Environ(), extra)
if claudeConfigDir == "" {
return env
}
// Drop any CLAUDE_CONFIG_DIR already in the slice (from os.Environ or
// from custom_env) before appending the isolated override so the last
// entry wins deterministically. Track whether the operator already
// pinned OAuth / API-key auth via env so we know whether to add a
// keychain-sourced token below. "Pinned" means the value is non-empty:
// an empty `ANTHROPIC_API_KEY=` / `CLAUDE_CODE_OAUTH_TOKEN=` leaks in
// from `os.Environ()` on hosts whose login shell unsets it that way,
// and treating that as an explicit auth choice would silently strand
// the isolated child at "Not logged in" (MUL-2603 review follow-up).
filtered := env[:0]
hasOAuthToken := false
hasAnthropicKey := false
for _, entry := range env {
if strings.HasPrefix(entry, "CLAUDE_CONFIG_DIR=") {
continue
}
if v, ok := strings.CutPrefix(entry, "CLAUDE_CODE_OAUTH_TOKEN="); ok {
if v == "" {
// Empty `CLAUDE_CODE_OAUTH_TOKEN=` is noise (login-shell
// quirk, stale custom_env entry) — drop so it cannot shadow
// a keychain-injected token later in the slice on platforms
// where the libc env lookup picks the first match.
continue
}
hasOAuthToken = true
}
if v, ok := strings.CutPrefix(entry, "ANTHROPIC_API_KEY="); ok {
if v == "" {
// Same treatment as the OAuth token: empty `ANTHROPIC_API_KEY=`
// must not pose as a pinned API-key auth choice. Drop so the
// child does not see a confusing empty value either.
continue
}
hasAnthropicKey = true
}
filtered = append(filtered, entry)
}
filtered = append(filtered, "CLAUDE_CONFIG_DIR="+claudeConfigDir)
// Auth passthrough — only meaningful in isolation mode, and only on
// platforms where the child CLI cannot follow the host's auth on its
// own. On Linux/Windows the OAuth token lives in
// `$CLAUDE_CONFIG_DIR/.credentials.json` and is already symlinked via
// mirrorHostClaudeExceptSkills, so readHostClaudeOAuthToken is a
// no-op there. On macOS Claude Code 2.x scopes the keychain entry by
// SHA-256(CLAUDE_CONFIG_DIR)[:8]; isolating the config dir changes
// that suffix, so the child cannot find the host token even though it
// is sitting in the default `Claude Code-credentials` entry. Surface
// the access token via CLAUDE_CODE_OAUTH_TOKEN so the child skips
// keychain lookup entirely (MUL-2603 follow-up regression).
//
// Host-dir gate: the reader returns the *unsuffixed*
// `Claude Code-credentials` entry, which is only correct when the
// host config source is the default `$HOME/.claude`. A custom
// CLAUDE_CONFIG_DIR (set via agent custom_env or daemon-host env)
// maps to `Claude Code-credentials-<hash>` for a *different* account.
// Injecting the unsuffixed entry there would cross-pollute the
// managed/custom agent with the daemon user's default OAuth account
// — the same "do not cross accounts" boundary mirrorHostClaudeJSONIfMissing
// already enforces for `.claude.json`. So for custom/parent dirs we
// deliberately skip the passthrough and let the child rely on
// whatever auth lives inside the custom dir (`.credentials.json`,
// `apiKeyHelper`, etc.) or fall through to ANTHROPIC_API_KEY.
//
// Security boundary (Bash subprocess only): CLAUDE_CODE_OAUTH_TOKEN
// is the host's claude.ai OAuth access token. We rely on Claude
// Code itself scrubbing it from the env it passes to the
// model-driven Bash tool subprocess — the property is locked in by
// TestClaudeCLIScrubsOAuthTokenFromBashSubprocess, which boots the
// real CLI with a canary OAuth token + a non-secret control var,
// plus two per-run random nonces passed *only* via env vars (never
// in the prompt text). The assertions are: the canary is absent
// from the transcript, the unset-nonce appears (proving a real Bash
// subprocess ran AND saw CLAUDE_CODE_OAUTH_TOKEN as empty), and the
// control-nonce appears (proving the scrub is targeted, not a
// side-effect of "Bash gets no env"). Using nonces — instead of
// hard-coded markers that could be paraphrased back from the
// prompt — is what makes the proof actually pin a real subprocess.
// Hook subprocesses are NOT covered by this assertion: we have not
// reproduced the env shape they receive, so the contract here
// intentionally narrows to Bash; if a future hook-using feature
// matters we must add a hook-side regression before relying on the
// same env-scrub there. If the existing test ever flips (upstream
// CLI change), this passthrough must move off env vars (e.g. a
// short-lived `apiKeyHelper` script) before merging.
//
// Precedence (highest wins): operator-pinned CLAUDE_CODE_OAUTH_TOKEN
// in custom_env, then ANTHROPIC_API_KEY (the user explicitly chose
// API-key auth — do not silently override with OAuth), then the
// keychain token. "Pinned" is gated on a non-empty value above so an
// empty `KEY=` inherited from os.Environ does not pose as an explicit
// choice and disable the keychain reader.
if !hasOAuthToken && !hasAnthropicKey && readOAuthToken != nil &&
isDefaultHostClaudeConfigDir(hostConfigDir, homeDir) {
token, err := readOAuthToken()
if err != nil && logger != nil {
logger.Warn("claude: read host oauth token failed",
"error", err,
)
}
if token != "" {
filtered = append(filtered, "CLAUDE_CODE_OAUTH_TOKEN="+token)
}
}
return filtered
}
// isDefaultHostClaudeConfigDir reports whether hostConfigDir refers to the
// default per-user Claude config path `$HOME/.claude`. The macOS keychain
// passthrough relies on this gate because the unsuffixed
// `Claude Code-credentials` entry only matches that default; custom
// CLAUDE_CONFIG_DIR values map to suffixed entries for different accounts,
// so reading the unsuffixed entry into a custom-dir isolated child would
// cross-pollute accounts (see buildClaudeEnvWith for the full reasoning).
//
// Returns false when hostConfigDir is empty (merge mode) or when the home
// directory cannot be resolved — both translate to "do not pull the
// default keychain token", which is the safe default.
func isDefaultHostClaudeConfigDir(hostConfigDir string, homeDir func() (string, error)) bool {
if hostConfigDir == "" || homeDir == nil {
return false
}
home, err := homeDir()
if err != nil || home == "" {
return false
}
return hostConfigDir == filepath.Join(home, ".claude")
}
func mergeEnv(base []string, extra map[string]string) []string {
env := make([]string, 0, len(base)+len(extra))
for _, entry := range base {
@@ -810,237 +588,6 @@ func isFilteredChildEnvKey(key string) bool {
strings.HasPrefix(key, "CLAUDE_CODE_")
}
// resolveHostClaudeConfigDir picks the directory we mirror into the per-task
// scratch CLAUDE_CONFIG_DIR. Precedence:
//
// 1. Agent custom_env CLAUDE_CONFIG_DIR (operator pinned this agent at a
// specific install — e.g. a managed shared profile).
// 2. Parent process CLAUDE_CONFIG_DIR (daemon-host env var; some self-host
// deployments set this globally so `claude login` writes there instead
// of `~/.claude`).
// 3. `~/.claude/` (the documented default for Linux/Windows; macOS keeps
// credentials in Keychain so the dir still holds settings/agents/etc.).
//
// Returns "" when none of the above are usable (no env, no home dir). The
// caller treats that as "host has no Claude config to mirror" — the scratch
// dir stays empty and the CLI relies on `ANTHROPIC_API_KEY` auth.
func resolveHostClaudeConfigDir(extraEnv map[string]string) string {
if v, ok := extraEnv["CLAUDE_CONFIG_DIR"]; ok && v != "" {
return v
}
if v := os.Getenv("CLAUDE_CONFIG_DIR"); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude")
}
// newIsolatedClaudeConfigDir creates a per-run scratch directory used as
// CLAUDE_CONFIG_DIR when the agent opted out of host-machine skill merging.
// The dir is placed under the task workdir when available so it lives and
// dies with the task's storage allocation; otherwise it falls back to the
// OS temp dir.
//
// The dir is NOT empty: every entry from the effective host Claude config
// (resolveHostClaudeConfigDir) is mirrored through *except* `skills/`. That
// preserves Claude Code's login state (`.credentials.json` on Linux/Windows),
// global settings, plugins, agents, commands, output styles, todos, etc. —
// which all live in this directory and would otherwise be invisible to the
// isolated child — while still keeping the user-global skills directory off
// the CLI's discovery path. Without this passthrough, opting into "ignore"
// mode on a non-API-key host would force the Claude agent to re-authenticate,
// breaking the documented "run `claude` once to log in" install flow.
//
// Each entry uses createDirLink / createFileLink, which try symlink first
// and fall back to a directory junction (Windows `mklink /J`) for dirs or a
// hardlink/copy for files when the platform rejects the symlink (Windows
// without Developer Mode / admin). Failing silently to symlink-only would
// leave Windows hosts with an empty scratch dir, defeating the auth
// passthrough — see MUL-2603 review.
//
// The returned cleanup removes the scratch directory; symlinks, junctions,
// and hardlinks are unlinked without touching the real host file. Safe to
// call from any goroutine exactly once.
func newIsolatedClaudeConfigDir(taskCwd, hostConfigDir string, logger *slog.Logger) (string, func(), error) {
parent := taskCwd
if parent == "" {
parent = os.TempDir()
}
dir, err := os.MkdirTemp(parent, "multica-claude-config-*")
if err != nil {
// Fall back to the OS temp dir if the cwd refused us (read-only
// volume, missing parent, etc.). A scratch dir somewhere on the
// host is still strictly better than letting the CLI auto-discover
// `~/.claude/skills/`.
if parent != os.TempDir() {
dir, err = os.MkdirTemp(os.TempDir(), "multica-claude-config-*")
}
if err != nil {
return "", nil, err
}
}
if hostConfigDir != "" {
// Best effort. If the host config dir is missing entirely (env-var-
// auth-only setup) we just leave the scratch dir empty. If individual
// entries fail to mirror — even after fallback — we log so operators
// can correlate Claude Code auth failures with the missing mirror,
// but we do not abort: a partial mirror is still better than letting
// the CLI discover `~/.claude/skills/` directly.
if err := mirrorHostClaudeExceptSkills(hostConfigDir, dir); err != nil && logger != nil {
logger.Warn("claude: mirror host config dir failed",
"source", hostConfigDir,
"dest", dir,
"error", err,
)
}
// Claude Code's default layout (no CLAUDE_CONFIG_DIR set) stores the
// main config — login state, projects history, recent sessions — at
// `$HOME/.claude.json`, a *sibling* of `~/.claude/` rather than
// inside it. The mirror above only walks entries under
// `hostConfigDir`, so on default hosts the scratch dir never gets
// `.claude.json` and the CLI exits with `Claude configuration file
// not found … Not logged in · Please run /login` (MUL-2661).
if err := mirrorHostClaudeJSONIfMissing(hostConfigDir, dir); err != nil && logger != nil {
logger.Warn("claude: mirror host .claude.json failed",
"source", hostConfigDir,
"dest", dir,
"error", err,
)
}
}
cleanup := func() {
// Best effort — caller logs through normal channels if cleanup fails
// (a leftover scratch dir is non-fatal; the GC sweeper that reclaims
// orphaned task workdirs will catch it too). RemoveAll unlinks
// symlinks / junctions without following them, so the real host
// `~/.claude/` is not touched. Hardlinks share inodes so unlinking
// the mirror entry leaves the source file with refcount 1.
_ = os.RemoveAll(dir)
}
return dir, cleanup, nil
}
// mirrorHostClaudeExceptSkills mirrors every direct entry under
// hostClaudeDir into destDir, skipping `skills/`. Each entry tries a
// symlink first and falls back to a directory junction (Windows
// `mklink /J`) or a hardlink/copy (Windows without Developer Mode) so the
// mirror still works on hosts that lack SeCreateSymbolicLinkPrivilege. The
// first per-entry error is returned so callers can log it; we do not abort
// the loop, because the goal — keeping `skills/` out of the CLI's discovery
// path — is met as soon as we own the scratch dir, and a partial mirror is
// still better than no isolation.
func mirrorHostClaudeExceptSkills(hostClaudeDir, destDir string) error {
return mirrorHostClaudeExceptSkillsWith(hostClaudeDir, destDir, createDirLink, createFileLink)
}
// mirrorHostClaudeExceptSkillsWith is the testable seam behind
// mirrorHostClaudeExceptSkills. Tests inject custom dirLink / fileLink
// closures to exercise the symlink-failure fallback path on platforms
// (Linux/macOS) where os.Symlink would otherwise always succeed.
func mirrorHostClaudeExceptSkillsWith(
hostClaudeDir, destDir string,
dirLink, fileLink func(src, dst string) error,
) error {
entries, err := os.ReadDir(hostClaudeDir)
if err != nil {
return err
}
var firstErr error
for _, entry := range entries {
if entry.Name() == "skills" {
continue
}
src := filepath.Join(hostClaudeDir, entry.Name())
dst := filepath.Join(destDir, entry.Name())
var linkErr error
if entry.IsDir() {
linkErr = dirLink(src, dst)
} else {
linkErr = fileLink(src, dst)
}
if linkErr != nil && firstErr == nil {
firstErr = fmt.Errorf("link %s: %w", entry.Name(), linkErr)
}
}
return firstErr
}
// mirrorHostClaudeJSONIfMissing links `$HOME/.claude.json` into destDir as
// `.claude.json` when it is not already there. Claude Code's default layout
// (no CLAUDE_CONFIG_DIR set) stores the main config — login state, project
// history — at `$HOME/.claude.json`, a *sibling* of `~/.claude/`, not inside
// it. Without this passthrough, isolating CLAUDE_CONFIG_DIR strands the CLI
// in a dir without `.claude.json` and it bails with
// `Claude configuration file not found … Not logged in · Please run /login`
// (MUL-2661 regression).
//
// No-op when:
// - destDir already has `.claude.json` (mirrored from inside a custom
// CLAUDE_CONFIG_DIR by mirrorHostClaudeExceptSkills);
// - hostConfigDir is not the default `$HOME/.claude` — a custom
// CLAUDE_CONFIG_DIR is expected to be self-contained, and silently
// merging `$HOME/.claude.json` from a different account would mask
// credential drift;
// - `$HOME/.claude.json` does not exist (env-var-auth-only or fresh
// install).
func mirrorHostClaudeJSONIfMissing(hostConfigDir, destDir string) error {
return mirrorHostClaudeJSONIfMissingWith(hostConfigDir, destDir, os.UserHomeDir, createFileLink)
}
// mirrorHostClaudeJSONIfMissingWith is the testable seam behind
// mirrorHostClaudeJSONIfMissing. Tests inject homeDir / fileLink so they can
// exercise the precedence rules without mutating the process environment.
func mirrorHostClaudeJSONIfMissingWith(
hostConfigDir, destDir string,
homeDir func() (string, error),
fileLink func(src, dst string) error,
) error {
dst := filepath.Join(destDir, ".claude.json")
if _, err := os.Lstat(dst); err == nil {
return nil
}
home, err := homeDir()
if err != nil || home == "" {
return nil
}
if hostConfigDir != filepath.Join(home, ".claude") {
return nil
}
src := filepath.Join(home, ".claude.json")
if _, err := os.Stat(src); err != nil {
return nil
}
return fileLink(src, dst)
}
// copyFile copies the bytes of src into dst with a fresh file. Used as the
// last-resort fallback inside createFileLink on Windows when both symlink
// and hardlink are unavailable. Kept in the platform-agnostic file so the
// Unix build still compiles (it never calls copyFile, but the symbol must
// exist for the test fallback seam to be exercisable on Linux/macOS).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s → %s: %w", src, dst, err)
}
return nil
}
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.
type blockedArgMode int
@@ -1,87 +0,0 @@
//go:build darwin
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
// claudeKeychainServiceName is the macOS keychain service name that
// `claude /login` writes to when the user authenticates against
// claude.ai. The payload is a JSON blob whose `claudeAiOauth.accessToken`
// field is what we surface to the isolated child.
const claudeKeychainServiceName = "Claude Code-credentials"
// claudeKeychainReadTimeout caps how long we wait on `/usr/bin/security`.
// Reads are normally instantaneous when the ACL already trusts the caller;
// when macOS surfaces a "allow access" prompt this gives a present user
// enough time to click through. We do NOT want to wait forever: a headless
// run with no one at the console would otherwise stall the agent task
// indefinitely on the very first run.
const claudeKeychainReadTimeout = 10 * time.Second
// readHostClaudeOAuthToken reads the host machine's Claude Code OAuth
// access token from the macOS login keychain (entry
// `Claude Code-credentials`). Returns ("", nil) when the token is not
// available — entry missing, user denied / ignored the keychain prompt,
// payload missing `claudeAiOauth.accessToken`, etc. — so callers in the
// isolation path can fall back to whatever auth the child CLI can find on
// its own (typically ANTHROPIC_API_KEY for headless / API-key-only hosts).
//
// Why this exists: Claude Code 2.x derives the keychain-lookup suffix from
// SHA-256(CLAUDE_CONFIG_DIR)[:8], so the unsuffixed `Claude Code-credentials`
// entry is read only when CLAUDE_CONFIG_DIR is unset or equals the install
// default. The moment MUL-2603 isolation points the child at a scratch dir,
// the CLI starts asking for `Claude Code-credentials-<other-hash>`, finds
// nothing, and exits "Not logged in · Please run /login" even though the
// host's OAuth token is sitting in the default entry. Surfacing the token
// through CLAUDE_CODE_OAUTH_TOKEN sidesteps the suffix scheme entirely.
//
// Returns a non-nil error only for unexpected system failures (e.g.
// `/usr/bin/security` not found) — the caller logs those at warn level so
// operators can diagnose. The common "no token available" case stays
// silent because we cannot tell "user is API-key-only" apart from "user
// denied access" by exit code, and we do not want to spam the daemon log
// on every isolated run for the former.
func readHostClaudeOAuthToken() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), claudeKeychainReadTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/security", "find-generic-password", "-s", claudeKeychainServiceName, "-w")
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// Non-zero exit from `security` covers entry-missing, denied,
// and timeout — all soft failures from our perspective.
return "", nil
}
// `/usr/bin/security` itself is missing or unexecutable — this is
// unexpected on macOS and worth surfacing so the caller can log it.
return "", fmt.Errorf("invoke security CLI: %w", err)
}
raw := strings.TrimSpace(string(out))
if raw == "" {
return "", nil
}
var payload struct {
ClaudeAiOauth struct {
AccessToken string `json:"accessToken"`
} `json:"claudeAiOauth"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
// A malformed payload is almost certainly a Claude Code upstream
// change to the keychain schema; downgrade to soft failure so
// the child can still attempt API-key auth, but do not pretend
// the schema is fine — surface so we update the parser.
return "", fmt.Errorf("parse claude keychain payload: %w", err)
}
return payload.ClaudeAiOauth.AccessToken, nil
}
-18
View File
@@ -1,18 +0,0 @@
//go:build !darwin
package agent
// readHostClaudeOAuthToken is a no-op on non-Darwin platforms. Linux and
// Windows Claude Code persists the OAuth token to
// `$CLAUDE_CONFIG_DIR/.credentials.json` — a file that lives inside the
// config dir, so mirrorHostClaudeExceptSkills already symlinks it into the
// per-run scratch dir alongside everything else. The isolated child finds
// it via the normal config-dir path; we do not need to surface anything
// through CLAUDE_CODE_OAUTH_TOKEN here.
//
// The macOS keychain workaround exists specifically because that platform
// derives the keychain-lookup suffix from the config-dir path — see
// claude_keychain_darwin.go for the full story.
func readHostClaudeOAuthToken() (string, error) {
return "", nil
}
-20
View File
@@ -1,20 +0,0 @@
//go:build !windows
package agent
import "os"
// createDirLink symlinks src → dst for a directory entry mirrored into the
// per-task scratch CLAUDE_CONFIG_DIR. On Unix the kernel always honours
// symlinks, so a single os.Symlink call is sufficient and matches the
// existing behaviour.
func createDirLink(src, dst string) error {
return os.Symlink(src, dst)
}
// createFileLink symlinks src → dst for a file entry mirrored into the
// per-task scratch CLAUDE_CONFIG_DIR. Unix has no equivalent of Windows's
// SeCreateSymbolicLinkPrivilege gate, so a single os.Symlink is enough.
func createFileLink(src, dst string) error {
return os.Symlink(src, dst)
}
-45
View File
@@ -1,45 +0,0 @@
//go:build windows
package agent
import (
"fmt"
"os"
"os/exec"
)
// createDirLink mirrors a directory from src to dst inside the per-task
// scratch CLAUDE_CONFIG_DIR. Windows os.Symlink requires
// SeCreateSymbolicLinkPrivilege (Developer Mode or admin), which most
// Multica CLI / Desktop installs do not have — without a fallback the
// mirror leaves an empty `~/.claude/`-equivalent, and the default
// "ignore" mode silently breaks Claude Code authentication on those
// hosts. A directory junction (`mklink /J`) does not require the
// privilege and behaves equivalently for our read-only mirror use, so
// it is the first fallback. See server/internal/daemon/execenv for the
// same pattern applied to CODEX_HOME.
func createDirLink(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
return nil
}
out, err := exec.Command("cmd", "/c", "mklink", "/J", dst, src).CombinedOutput()
if err != nil {
return fmt.Errorf("mklink /J %s %s: %s: %w", dst, src, out, err)
}
return nil
}
// createFileLink mirrors a file from src to dst inside the per-task scratch
// CLAUDE_CONFIG_DIR. Tries symlink first, then hardlink (works on the same
// NTFS volume without elevated privileges and still shares content with the
// shared `~/.claude/.credentials.json` so token refreshes propagate), then
// a final content copy as a last resort.
func createFileLink(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
return nil
}
if err := os.Link(src, dst); err == nil {
return nil
}
return copyFile(src, dst)
}
File diff suppressed because it is too large Load Diff
+19 -42
View File
@@ -14,7 +14,7 @@ import (
const archiveAgent = `-- name: ArchiveAgent :one
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type ArchiveAgentParams struct {
@@ -48,7 +48,6 @@ func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Age
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -57,7 +56,7 @@ const archiveAgentsByIDs = `-- name: ArchiveAgentsByIDs :many
UPDATE agent
SET archived_at = now(), archived_by = $1, updated_at = now()
WHERE id = ANY($2::uuid[]) AND archived_at IS NULL
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type ArchiveAgentsByIDsParams struct {
@@ -105,7 +104,6 @@ func (q *Queries) ArchiveAgentsByIDs(ctx context.Context, arg ArchiveAgentsByIDs
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
); err != nil {
return nil, err
}
@@ -121,7 +119,7 @@ const archiveAgentsByRuntime = `-- name: ArchiveAgentsByRuntime :many
UPDATE agent
SET archived_at = now(), archived_by = $1, updated_at = now()
WHERE runtime_id = ANY($2::uuid[]) AND archived_at IS NULL
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type ArchiveAgentsByRuntimeParams struct {
@@ -165,7 +163,6 @@ func (q *Queries) ArchiveAgentsByRuntime(ctx context.Context, arg ArchiveAgentsB
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
); err != nil {
return nil, err
}
@@ -586,7 +583,7 @@ func (q *Queries) ClaimAgentTask(ctx context.Context, agentID pgtype.UUID) (Agen
const clearAgentMcpConfig = `-- name: ClearAgentMcpConfig :one
UPDATE agent SET mcp_config = NULL, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agent, error) {
@@ -615,7 +612,6 @@ func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agen
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -623,7 +619,7 @@ func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agen
const clearAgentThinkingLevel = `-- name: ClearAgentThinkingLevel :one
UPDATE agent SET thinking_level = NULL, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
// Explicit NULL-clear for thinking_level. COALESCE-based UpdateAgent cannot
@@ -655,7 +651,6 @@ func (q *Queries) ClearAgentThinkingLevel(ctx context.Context, id pgtype.UUID) (
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -728,10 +723,9 @@ const createAgent = `-- name: CreateAgent :one
INSERT INTO agent (
workspace_id, name, description, avatar_url, runtime_mode,
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args, mcp_config, model, thinking_level,
skills_local
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
instructions, custom_env, custom_args, mcp_config, model, thinking_level
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type CreateAgentParams struct {
@@ -751,7 +745,6 @@ type CreateAgentParams struct {
McpConfig []byte `json:"mcp_config"`
Model pgtype.Text `json:"model"`
ThinkingLevel pgtype.Text `json:"thinking_level"`
SkillsLocal string `json:"skills_local"`
}
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
@@ -772,7 +765,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
arg.McpConfig,
arg.Model,
arg.ThinkingLevel,
arg.SkillsLocal,
)
var i Agent
err := row.Scan(
@@ -798,7 +790,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -1214,7 +1205,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
}
const getAgent = `-- name: GetAgent :one
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
WHERE id = $1
`
@@ -1244,13 +1235,12 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
WHERE id = $1 AND workspace_id = $2
`
@@ -1285,7 +1275,6 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -1590,7 +1579,7 @@ func (q *Queries) LinkTaskToIssue(ctx context.Context, arg LinkTaskToIssueParams
}
const listActiveAgentsByRuntime = `-- name: ListActiveAgentsByRuntime :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
WHERE runtime_id = $1 AND archived_at IS NULL
ORDER BY name ASC
`
@@ -1633,7 +1622,6 @@ func (q *Queries) ListActiveAgentsByRuntime(ctx context.Context, runtimeID pgtyp
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
); err != nil {
return nil, err
}
@@ -1646,7 +1634,7 @@ func (q *Queries) ListActiveAgentsByRuntime(ctx context.Context, runtimeID pgtyp
}
const listActiveAgentsByRuntimeForUpdate = `-- name: ListActiveAgentsByRuntimeForUpdate :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
WHERE runtime_id = $1 AND archived_at IS NULL
ORDER BY name ASC
FOR UPDATE
@@ -1692,7 +1680,6 @@ func (q *Queries) ListActiveAgentsByRuntimeForUpdate(ctx context.Context, runtim
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
); err != nil {
return nil, err
}
@@ -1814,7 +1801,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
}
const listAgents = `-- name: ListAgents :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
WHERE workspace_id = $1 AND archived_at IS NULL
ORDER BY created_at ASC
`
@@ -1851,7 +1838,6 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
); err != nil {
return nil, err
}
@@ -1864,7 +1850,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
}
const listAllAgents = `-- name: ListAllAgents :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
WHERE workspace_id = $1
ORDER BY created_at ASC
`
@@ -1901,7 +1887,6 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
); err != nil {
return nil, err
}
@@ -2286,7 +2271,7 @@ SET status = CASE WHEN EXISTS (
) THEN 'working' ELSE 'idle' END,
updated_at = now()
WHERE a.id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUID) (Agent, error) {
@@ -2315,7 +2300,6 @@ func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUI
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -2323,7 +2307,7 @@ func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUI
const restoreAgent = `-- name: RestoreAgent :one
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
@@ -2352,7 +2336,6 @@ func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, erro
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -2414,10 +2397,9 @@ UPDATE agent SET
mcp_config = COALESCE($14, mcp_config),
model = COALESCE($15, model),
thinking_level = COALESCE($16, thinking_level),
skills_local = COALESCE($17, skills_local),
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type UpdateAgentParams struct {
@@ -2437,7 +2419,6 @@ type UpdateAgentParams struct {
McpConfig []byte `json:"mcp_config"`
Model pgtype.Text `json:"model"`
ThinkingLevel pgtype.Text `json:"thinking_level"`
SkillsLocal pgtype.Text `json:"skills_local"`
}
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
@@ -2458,7 +2439,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
arg.McpConfig,
arg.Model,
arg.ThinkingLevel,
arg.SkillsLocal,
)
var i Agent
err := row.Scan(
@@ -2484,7 +2464,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -2493,7 +2472,7 @@ const updateAgentCustomEnv = `-- name: UpdateAgentCustomEnv :one
UPDATE agent
SET custom_env = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type UpdateAgentCustomEnvParams struct {
@@ -2532,7 +2511,6 @@ func (q *Queries) UpdateAgentCustomEnv(ctx context.Context, arg UpdateAgentCusto
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
@@ -2540,7 +2518,7 @@ func (q *Queries) UpdateAgentCustomEnv(ctx context.Context, arg UpdateAgentCusto
const updateAgentStatus = `-- name: UpdateAgentStatus :one
UPDATE agent SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
`
type UpdateAgentStatusParams struct {
@@ -2574,7 +2552,6 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
&i.McpConfig,
&i.Model,
&i.ThinkingLevel,
&i.SkillsLocal,
)
return i, err
}
-1
View File
@@ -44,7 +44,6 @@ type Agent struct {
McpConfig []byte `json:"mcp_config"`
Model pgtype.Text `json:"model"`
ThinkingLevel pgtype.Text `json:"thinking_level"`
SkillsLocal string `json:"skills_local"`
}
type AgentRuntime struct {
+2 -4
View File
@@ -20,9 +20,8 @@ WHERE id = $1 AND workspace_id = $2;
INSERT INTO agent (
workspace_id, name, description, avatar_url, runtime_mode,
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args, mcp_config, model, thinking_level,
skills_local
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
instructions, custom_env, custom_args, mcp_config, model, thinking_level
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *;
-- name: UpdateAgent :one
@@ -42,7 +41,6 @@ UPDATE agent SET
mcp_config = COALESCE(sqlc.narg('mcp_config'), mcp_config),
model = COALESCE(sqlc.narg('model'), model),
thinking_level = COALESCE(sqlc.narg('thinking_level'), thinking_level),
skills_local = COALESCE(sqlc.narg('skills_local'), skills_local),
updated_at = now()
WHERE id = $1
RETURNING *;