mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)
Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).
- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
CompleteCallback (idempotent upsert), ListConnections, Disconnect
(upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
handlers (httptest), redact regression for Bearer mcp_ tokens
MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)
Address PR 4608 review (CHANGES_REQUESTED):
- callback: verify connected_account_id with Composio before mirroring it.
The signed state only proved user/toolkit/exp, so a valid state paired with
a tampered connected_account_id would be written verbatim. CompleteCallback
now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
unless the account belongs to the state's user (composio_user_id == multica
user id) and was created under the toolkit's auth config. No row is written
on mismatch / unknown account / upstream error.
- disconnect: short-circuit to a no-op when the local row is already revoked,
before touching upstream. Previously a second DELETE re-hit Composio and a
non-404 upstream error surfaced as a 502, breaking the 204-idempotent
contract.
- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
constraint and make duplicate selection deterministic (newest-wins, rows are
connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
real single-account-enforcement vs multi-account-shape decision.
Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)
Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.
Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
is now resolved at request time from the project's /auth_configs (cached,
5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
to exactly one project; the project is resolved from the key. Only org-level
endpoints use x-org-api-key, which this integration never calls.
Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
custom/white-label config over Composio-managed, newest wins); BeginConnect
and CompleteCallback resolve via it; ListToolkits fetches the full catalog
(paginated, capped) annotated with connectable = has an enabled auth config,
connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.
Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
toolkit as a card with search. Connect is gated on `connectable`;
non-connectable toolkits show a muted "not configured" hint instead of a
dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.
Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)
Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.
Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.
Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.
Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)
Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.
Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)
* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)
* fix(composio): move OAuth callback out of the Auth group (MUL-3843)
Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.
Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.
Refs MUL-3843, MUL-3715.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): correct stale callback routing comments (MUL-3843)
The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.
Refs MUL-3843.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)
Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.
Server side
- Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
BEFORE-UPDATE trigger that wipes the column on any transition into a
terminal status (completed / failed / cancelled). A trigger is the single
source of truth — future queries that flip status cannot bypass it.
- composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
emits the Claude-style { mcpServers: { composio: { type: http, url,
headers } } } shape the daemon's existing sidecar generators consume.
Returns (nil, nil) on zero active connections so we never burn a
Composio session for a user with nothing to call.
- TaskService grows a Composio ComposioOverlayBuilder seam, wired in
router.go after composiointeg.NewService succeeds. Five enqueue paths
(issue / mention / quick-create / chat / auto-retry) attach the overlay
after CreateAgentTask returns and before the daemon is notified — so
every claim reads a settled row, with no second daemon hop. Best-effort:
a builder failure logs and proceeds with no overlay.
- resolveInitiatorFromTriggerComment derives the initiator user from the
trigger comment when it was authored by a member. Agent-authored
triggers are not treated as initiators (their connected-apps view is
empty by construction).
Daemon side
- handler/daemon.go claim path merges task.runtime_mcp_overlay onto
agent.mcp_config via mergeMCPOverlay before populating
TaskAgentData.McpConfig. Overlay wins on server-name collisions
because it carries the live user-scoped session URL. Errors fall back
to the agent config unchanged — a bad overlay must not surprise-disable
saved MCP tools. The existing execenv sidecar generators (cursor /
codex / openclaw / opencode / hermes-kiro) need no changes: they keep
consuming the merged result through TaskAgentData.McpConfig.
Tests
- 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
pass-through, overlay-only canonicalization, two-side merge, name
collision (overlay wins), top-level key preservation, malformed agent
fallback, malformed overlay fallback, non-object server rejection.
- 4 dispatch cases (composio): zero-connections returns nil without
CreateSession, happy-path emits the right shape with the right user
id, empty-URL defensive branch, SDK error surfacing.
- 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
invalid initiator does not call the builder, nil overlay skips the
UPDATE, builder error swallowed without panic.
- Migration 128 verified to roll up + down + up cleanly against the test
database.
Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)
Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:
- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
Owner-only read/write; non-owner GET/PUT silently redacts/drops the
field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
top-of-chain HUMAN at every enqueue path:
* issue/mention comment by member -> author_id
* issue/mention comment by agent -> inherit via comment.source_task_id
-> parent task originator_user_id
* quick-create -> requester_id
* chat -> initiator_user_id
* retry -> SQL-inherited from parent row
* autopilot -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
agent) and short-circuits on five gates: invalid originator,
originator != agent.owner_id, empty allowlist, empty intersection of
allowlist ∩ active connections, defensive empty session URL. Composio
CreateSession is called with BOTH `toolkits.slugs` (the intersection)
AND `connected_accounts` (the pinned account ids), narrowing the
tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
workspace member who can @-mention a public agent used to project the
owner's connected apps into their run. Now the overlay only mounts
when the human at the top of the chain IS the agent owner.
Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
(list/empty/null), workspace-admin silent-drop, owner-only GET
visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
agent-authored inherits via comment.source_task_id, invalid id.
Migration 129 up/down/up verified against docker postgres.
Co-authored-by: multica-agent <github@multica.ai>
* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): accept nested connected account auth config
* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)
Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.
- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
is gated to the creator (currentUserId === agent.owner_id), matching the
backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
non-creator hidden) with Composio + agent endpoints mocked at the boundary.
Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): mount remote MCP for codex
* feat(agents): agent invocation permission system (MUL-3963) (#4844)
* feat(agents): agent invocation permission system (permission_mode + invocation targets)
MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.
- DB: agent.permission_mode (private|public_to) + agent_invocation_target
table (workspace/member/team targets) + lossless backfill from visibility
(migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
create/update (owner-only writes); legacy visibility kept as a derived field
so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
agent OWNER connection (removed the originator==owner gate); front-end warns
when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
--visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
workspace/member whitelist, A2A by originator, Composio overlay uses owner
connection.
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)
Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).
- Frontend AccessPicker: reworked from a single exclusive kind into a
stackable multi-select — an "Everyone in workspace" toggle, a member
multi-select checklist, and a (disabled, v1) team placeholder can be
combined freely. Emits the full union of selected targets; empty union
collapses to Private. Existing team targets are preserved across saves.
Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
agent; create/update take a target ARRAY and batch-replace the whole
allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
mixed member+team targets, overlapping-member batch replace, and
workspace+member stacking then narrowing.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): address review on invocation permission (MUL-3963)
张大彪 review on PR #4844 — three blockers + product ruling + nits:
1. Migration 130: drop the FK/cascade on agent_invocation_target
(agent_id, created_by) per the Multica no-FK rule; relationships are now
maintained in the app layer (matching MUL-3515 §4). Added
DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
DeleteArchivedAgentsByRuntime in all three runtime-delete paths
(runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
(DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
public_to with no resolvable targets to a single workspace target, so
`--permission-mode public_to` alone (and any empty target array) means
"public to workspace" instead of "shared but nobody can run it".
Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.
Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.
Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)
张大彪 3rd review — cross-workspace permission bug + comment nits:
- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
removing a user from workspace A also wiped their member-target grants on
agents in workspace B. Scoped it to a single workspace by joining through
agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
same user allow-listed by agents in two workspaces; removal from one leaves
the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
"owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
rationale in agent.go — to the owner-connection + invocation-permission rule.
Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)
* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)
Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.
Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
viewer is not the owner: the current access value + a lock affordance + a
tooltip "Only the agent owner can change who can run this agent." No clickable
trigger is rendered, so a non-owner can never open a control the backend would
reject (the GitHub/Notion pattern for permission settings you can see but not
edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
(currentUserId === agent.owner_id), NOT the general canEdit (which also admits
admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).
Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
permission change (permissionInputChangesAgent compares requested mode +
target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
echoing unchanged permission) is still tolerated so admin edits of other
fields keep working. Replaces the previous silent-drop that caused the bounce.
Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
with the owner-only tooltip; owner gets an interactive picker; owner can pick
a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
resubmit → 200; admin editing other fields → 200; owner change → 200.
Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)
PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.
Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.
Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.
Go handler/composio suites green; migration 130 applied; go vet clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): brief agents on connected apps
* feat(composio): gate MCP apps behind feature flag
* fix(mobile): parse agent invocation permissions
* fix(tests): update agent fixtures for access fields
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
1275 lines
44 KiB
Go
1275 lines
44 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
"github.com/multica-ai/multica/server/internal/daemon"
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
var agentCmd = &cobra.Command{
|
|
Use: "agent",
|
|
Short: "Work with agents",
|
|
}
|
|
|
|
var agentListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List agents in the workspace",
|
|
RunE: runAgentList,
|
|
}
|
|
|
|
var agentGetCmd = &cobra.Command{
|
|
Use: "get <id>",
|
|
Short: "Get agent details",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentGet,
|
|
}
|
|
|
|
var agentCreateCmd = &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create a new agent",
|
|
RunE: runAgentCreate,
|
|
}
|
|
|
|
var agentUpdateCmd = &cobra.Command{
|
|
Use: "update <id>",
|
|
Short: "Update an agent",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentUpdate,
|
|
}
|
|
|
|
var agentArchiveCmd = &cobra.Command{
|
|
Use: "archive <id>",
|
|
Short: "Archive an agent",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentArchive,
|
|
}
|
|
|
|
var agentRestoreCmd = &cobra.Command{
|
|
Use: "restore <id>",
|
|
Short: "Restore an archived agent",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentRestore,
|
|
}
|
|
|
|
var agentTasksCmd = &cobra.Command{
|
|
Use: "tasks <id>",
|
|
Short: "List tasks for an agent",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentTasks,
|
|
}
|
|
|
|
var agentAvatarCmd = &cobra.Command{
|
|
Use: "avatar <id>",
|
|
Short: "Upload an avatar image for an agent",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentAvatar,
|
|
}
|
|
|
|
// Agent skills subcommands.
|
|
|
|
var agentSkillsCmd = &cobra.Command{
|
|
Use: "skills",
|
|
Short: "Manage agent skill assignments",
|
|
}
|
|
|
|
// Agent env subcommands. Live behind a dedicated `agent env` group because
|
|
// they're the ONLY post-creation path for reading or writing
|
|
// custom_env values — `multica agent list / get / update` no longer
|
|
// expose env on the wire. Each call hits the audited
|
|
// `/api/agents/{id}/env` endpoint. See MUL-2600.
|
|
|
|
var agentEnvCmd = &cobra.Command{
|
|
Use: "env",
|
|
Short: "Read and update an agent's custom environment variables (audited)",
|
|
}
|
|
|
|
var agentEnvGetCmd = &cobra.Command{
|
|
Use: "get <agent-id>",
|
|
Short: "Print an agent's custom_env as a JSON map (workspace owner/admin only; every call is recorded)",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentEnvGet,
|
|
}
|
|
|
|
var agentEnvSetCmd = &cobra.Command{
|
|
Use: "set <agent-id>",
|
|
Short: "Replace an agent's custom_env (workspace owner/admin only; values equal to **** preserve the existing entry)",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentEnvSet,
|
|
}
|
|
|
|
var agentSkillsListCmd = &cobra.Command{
|
|
Use: "list <agent-id>",
|
|
Short: "List skills assigned to an agent",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentSkillsList,
|
|
}
|
|
|
|
var agentSkillsSetCmd = &cobra.Command{
|
|
Use: "set <agent-id>",
|
|
Short: "Set skills for an agent (replaces all current assignments)",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentSkillsSet,
|
|
}
|
|
|
|
var agentSkillsAddCmd = &cobra.Command{
|
|
Use: "add <agent-id>",
|
|
Short: "Add skills to an agent without replacing existing assignments",
|
|
Args: exactArgs(1),
|
|
RunE: runAgentSkillsAdd,
|
|
}
|
|
|
|
func init() {
|
|
agentCmd.AddCommand(agentListCmd)
|
|
agentCmd.AddCommand(agentGetCmd)
|
|
agentCmd.AddCommand(agentCreateCmd)
|
|
agentCmd.AddCommand(agentUpdateCmd)
|
|
agentCmd.AddCommand(agentArchiveCmd)
|
|
agentCmd.AddCommand(agentRestoreCmd)
|
|
agentCmd.AddCommand(agentTasksCmd)
|
|
agentCmd.AddCommand(agentAvatarCmd)
|
|
agentCmd.AddCommand(agentSkillsCmd)
|
|
agentCmd.AddCommand(agentEnvCmd)
|
|
|
|
agentSkillsCmd.AddCommand(agentSkillsListCmd)
|
|
agentSkillsCmd.AddCommand(agentSkillsSetCmd)
|
|
agentSkillsCmd.AddCommand(agentSkillsAddCmd)
|
|
|
|
agentEnvCmd.AddCommand(agentEnvGetCmd)
|
|
agentEnvCmd.AddCommand(agentEnvSetCmd)
|
|
|
|
// agent list
|
|
agentListCmd.Flags().String("output", "table", "Output format: table or json")
|
|
agentListCmd.Flags().Bool("include-archived", false, "Include archived agents")
|
|
|
|
// agent get
|
|
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent create
|
|
agentCreateCmd.Flags().String("name", "", "Agent name (required)")
|
|
agentCreateCmd.Flags().String("description", "", "Agent description")
|
|
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
|
|
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
|
|
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
|
|
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
|
|
agentCreateCmd.Flags().String("thinking-level", "", "Reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex: none|minimal|low|medium|high|xhigh). The set is runtime/model-specific and validated server-side — an unknown value is rejected. Empty = runtime default.")
|
|
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
|
agentCreateCmd.Flags().String("custom-env", "", "Custom environment variables as JSON object, e.g. '{\"KEY\":\"value\"}'. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --custom-env-stdin or --custom-env-file for real secrets. Pass '{}' to set an empty map.")
|
|
agentCreateCmd.Flags().Bool("custom-env-stdin", false, "Read the --custom-env JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --custom-env and --custom-env-file.")
|
|
agentCreateCmd.Flags().String("custom-env-file", "", "Read the --custom-env JSON object from a file path (suggested mode: 0600). Mutually exclusive with --custom-env and --custom-env-stdin.")
|
|
agentCreateCmd.Flags().String("mcp-config", "", "MCP server configuration as a JSON object, e.g. '{\"mcpServers\":{\"shortcut\":{...}}}'. Treated as secret material (MCP entries often carry API tokens) — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --mcp-config-stdin or --mcp-config-file for real secrets.")
|
|
agentCreateCmd.Flags().Bool("mcp-config-stdin", false, "Read the --mcp-config JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --mcp-config and --mcp-config-file.")
|
|
agentCreateCmd.Flags().String("mcp-config-file", "", "Read the --mcp-config JSON object from a file path (suggested mode: 0600). Mutually exclusive with --mcp-config and --mcp-config-stdin.")
|
|
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace (legacy; mapped to --permission-mode. private->private, workspace->public_to+workspace target)")
|
|
agentCreateCmd.Flags().String("permission-mode", "", "Invocation permission mode: private (owner only) or public_to (allow-list via --public-to-*). Authoritative over --visibility when set.")
|
|
agentCreateCmd.Flags().Bool("public-to-workspace", false, "public_to: allow every workspace member to invoke this agent.")
|
|
agentCreateCmd.Flags().StringSlice("public-to-member", nil, "public_to: allow the given member user id(s) to invoke this agent. Repeatable.")
|
|
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
|
|
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent update
|
|
agentUpdateCmd.Flags().String("name", "", "New name")
|
|
agentUpdateCmd.Flags().String("description", "", "New description")
|
|
agentUpdateCmd.Flags().String("instructions", "", "New instructions")
|
|
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
|
|
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
|
|
agentUpdateCmd.Flags().String("model", "", "New model identifier. Pass an empty string to clear and fall back to the runtime default.")
|
|
agentUpdateCmd.Flags().String("thinking-level", "", "New reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex: none|minimal|low|medium|high|xhigh). The set is runtime/model-specific and validated server-side. Pass an empty string to clear and fall back to the runtime default.")
|
|
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
|
// custom_env is intentionally NOT part of `agent update`. Use
|
|
// `multica agent env set <id>` — that path is owner/admin-only,
|
|
// denies agent actors, and writes a persisted audit trail.
|
|
//
|
|
// mcp_config, unlike custom_env, IS updatable here: it is persisted
|
|
// through the generic UpdateAgent endpoint (there is no dedicated
|
|
// audited endpoint for it). The same three secret-safe input channels
|
|
// as `agent create` are offered. Pass `--mcp-config null` to clear.
|
|
agentUpdateCmd.Flags().String("mcp-config", "", "New MCP server configuration as a JSON object, e.g. '{\"mcpServers\":{...}}'. Pass 'null' to clear. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --mcp-config-stdin or --mcp-config-file for real secrets.")
|
|
agentUpdateCmd.Flags().Bool("mcp-config-stdin", false, "Read the --mcp-config JSON from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --mcp-config and --mcp-config-file.")
|
|
agentUpdateCmd.Flags().String("mcp-config-file", "", "Read the --mcp-config JSON from a file path (suggested mode: 0600). Mutually exclusive with --mcp-config and --mcp-config-stdin.")
|
|
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace (legacy; mapped to --permission-mode)")
|
|
agentUpdateCmd.Flags().String("permission-mode", "", "New invocation permission mode: private or public_to. Authoritative over --visibility. Owner-only.")
|
|
agentUpdateCmd.Flags().Bool("public-to-workspace", false, "public_to: allow every workspace member to invoke this agent.")
|
|
agentUpdateCmd.Flags().StringSlice("public-to-member", nil, "public_to: allow the given member user id(s) to invoke this agent. Repeatable.")
|
|
agentUpdateCmd.Flags().String("status", "", "New status")
|
|
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
|
|
agentUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent archive
|
|
agentArchiveCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent restore
|
|
agentRestoreCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent tasks
|
|
agentTasksCmd.Flags().String("output", "table", "Output format: table or json")
|
|
|
|
// agent avatar
|
|
agentAvatarCmd.Flags().String("file", "", "Path to the avatar image file (required)")
|
|
agentAvatarCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent skills list
|
|
agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json")
|
|
|
|
// agent skills set
|
|
agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)")
|
|
agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent skills add
|
|
agentSkillsAddCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to add (comma-separated)")
|
|
agentSkillsAddCmd.Flags().String("output", "json", "Output format: table or json")
|
|
|
|
// agent env get
|
|
agentEnvGetCmd.Flags().String("output", "json", "Output format: json or table")
|
|
|
|
// agent env set. Same three secret-safe input channels as `agent
|
|
// create` so scripts can keep secrets out of shell history. Mutual
|
|
// exclusion + empty-input handling is enforced by resolveCustomEnv.
|
|
agentEnvSetCmd.Flags().String("custom-env", "", "Replacement custom_env as a JSON object, e.g. '{\"KEY\":\"value\"}'. Values equal to '****' preserve the existing entry. Treated as secret material — values passed on the command line are visible to shell history and 'ps'; prefer --custom-env-stdin or --custom-env-file for real secrets. Pass '{}' to clear all keys.")
|
|
agentEnvSetCmd.Flags().Bool("custom-env-stdin", false, "Read the replacement custom_env JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --custom-env and --custom-env-file.")
|
|
agentEnvSetCmd.Flags().String("custom-env-file", "", "Read the replacement custom_env JSON object from a file path (suggested mode: 0600). Mutually exclusive with --custom-env and --custom-env-stdin.")
|
|
agentEnvSetCmd.Flags().String("output", "json", "Output format: json or table")
|
|
}
|
|
|
|
// resolveProfile returns the --profile flag value (empty string means default profile).
|
|
func resolveProfile(cmd *cobra.Command) string {
|
|
val, _ := cmd.Flags().GetString("profile")
|
|
return val
|
|
}
|
|
|
|
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
|
|
serverURL := resolveServerURL(cmd)
|
|
workspaceID := resolveWorkspaceID(cmd)
|
|
token := resolveToken(cmd)
|
|
|
|
if serverURL == "" {
|
|
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url <url>'")
|
|
}
|
|
if inDaemonManagedExecutionContext() && !strings.HasPrefix(token, "mat_") {
|
|
// When the ONLY daemon signal is a workdir marker (no MULTICA_AGENT_ID /
|
|
// MULTICA_TASK_ID / MULTICA_DAEMON_PORT), the likeliest cause outside a
|
|
// real task is a leftover marker from a crashed daemon task in a
|
|
// local_directory. Name the exact file so a normal user can recover
|
|
// instead of hitting an opaque "requires mat_ token" error.
|
|
if !inAgentExecutionContext() && os.Getenv("MULTICA_DAEMON_PORT") == "" {
|
|
if markerPath := daemonTaskContextMarkerPath(); markerPath != "" {
|
|
return nil, fmt.Errorf("agent execution context requires MULTICA_TOKEN to be a task-scoped mat_ token; detected a daemon task marker at %s — if you are not running inside an agent task this is likely a leftover, remove it and retry", markerPath)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("agent execution context requires MULTICA_TOKEN to be a task-scoped mat_ token")
|
|
}
|
|
|
|
client := cli.NewAPIClient(serverURL, workspaceID, token)
|
|
// When running inside a daemon task, attribute actions to the agent.
|
|
if agentID := os.Getenv("MULTICA_AGENT_ID"); agentID != "" {
|
|
client.AgentID = agentID
|
|
}
|
|
if taskID := os.Getenv("MULTICA_TASK_ID"); taskID != "" {
|
|
client.TaskID = taskID
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func resolveServerURL(cmd *cobra.Command) string {
|
|
val := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "")
|
|
if val != "" {
|
|
return normalizeAPIBaseURL(val)
|
|
}
|
|
profile := resolveProfile(cmd)
|
|
cfg, err := cli.LoadCLIConfigForProfile(profile)
|
|
if err == nil && cfg.ServerURL != "" {
|
|
return normalizeAPIBaseURL(cfg.ServerURL)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "No server configured. Run 'multica setup' first.")
|
|
os.Exit(1)
|
|
return "" // unreachable
|
|
}
|
|
|
|
func normalizeAPIBaseURL(raw string) string {
|
|
normalized, err := daemon.NormalizeServerBaseURL(raw)
|
|
if err == nil {
|
|
return normalized
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// inAgentExecutionContext reports whether the CLI has explicit task identity
|
|
// markers from a daemon-managed agent task.
|
|
func inAgentExecutionContext() bool {
|
|
return os.Getenv("MULTICA_AGENT_ID") != "" || os.Getenv("MULTICA_TASK_ID") != ""
|
|
}
|
|
|
|
// inDaemonManagedExecutionContext reports whether the CLI is being invoked
|
|
// from inside a daemon-managed agent task. MULTICA_DAEMON_PORT is included as
|
|
// a defense-in-depth marker for subprocesses that lose MULTICA_AGENT_ID or
|
|
// MULTICA_TASK_ID but still run under the daemon environment. In this context
|
|
// workspace and token must come from daemon-provided env; falling back to
|
|
// user-global ~/.multica/config.json can make agent writes land as a member.
|
|
func inDaemonManagedExecutionContext() bool {
|
|
return inAgentExecutionContext() || os.Getenv("MULTICA_DAEMON_PORT") != "" || hasDaemonTaskContextMarker()
|
|
}
|
|
|
|
func hasDaemonTaskContextMarker() bool {
|
|
return daemonTaskContextMarkerPath() != ""
|
|
}
|
|
|
|
// daemonTaskContextMarkerPath walks up from the current working directory and
|
|
// returns the path of the first readable daemon-task marker whose managed_by
|
|
// matches, or "" when none is found.
|
|
func daemonTaskContextMarkerPath() string {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for {
|
|
markerPath := filepath.Join(dir, execenv.TaskContextMarkerRelPath)
|
|
// Only a marker we can read AND whose managed_by matches counts as a
|
|
// daemon-task signal. Any other outcome — missing file, unreadable
|
|
// path, or a foreign file at this name — is treated as "no signal
|
|
// here", so we keep walking up. We must not fail closed on an
|
|
// unrelated read error (e.g. an unsearchable ancestor directory on a
|
|
// normal user's machine), which would refuse their PAT for no reason;
|
|
// the daemon writes this marker world-readable in the agent's own
|
|
// workdir, so a legitimate agent can always read it.
|
|
if data, err := os.ReadFile(markerPath); err == nil {
|
|
var marker struct {
|
|
ManagedBy string `json:"managed_by"`
|
|
}
|
|
if json.Unmarshal(data, &marker) == nil && marker.ManagedBy == execenv.TaskContextMarkerManagedBy {
|
|
return markerPath
|
|
}
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return ""
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func resolveWorkspaceID(cmd *cobra.Command) string {
|
|
val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "")
|
|
if val != "" {
|
|
return val
|
|
}
|
|
// Inside an agent task the daemon is the only authority on workspace
|
|
// identity. Never read the user-global CLI config here.
|
|
if inDaemonManagedExecutionContext() {
|
|
return ""
|
|
}
|
|
profile := resolveProfile(cmd)
|
|
cfg, _ := cli.LoadCLIConfigForProfile(profile)
|
|
return cfg.WorkspaceID
|
|
}
|
|
|
|
// requireWorkspaceID resolves the workspace ID and returns an error with
|
|
// actionable instructions if it is empty (e.g. user has multiple workspaces
|
|
// but no default configured).
|
|
func requireWorkspaceID(cmd *cobra.Command) (string, error) {
|
|
id := resolveWorkspaceID(cmd)
|
|
if id == "" {
|
|
if inDaemonManagedExecutionContext() {
|
|
return "", fmt.Errorf("workspace_id is required: MULTICA_WORKSPACE_ID must be set by the daemon in agent execution context (no fallback to user config)")
|
|
}
|
|
return "", fmt.Errorf("workspace_id is required: use --workspace-id flag, set MULTICA_WORKSPACE_ID env, or run 'multica config set workspace_id <id>'")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func runAgentList(cmd *cobra.Command, _ []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if client.WorkspaceID == "" {
|
|
if _, err := requireWorkspaceID(cmd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var agents []map[string]any
|
|
params := url.Values{}
|
|
params.Set("workspace_id", client.WorkspaceID)
|
|
if v, _ := cmd.Flags().GetBool("include-archived"); v {
|
|
params.Set("include_archived", "true")
|
|
}
|
|
path := "/api/agents"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
if err := client.GetJSON(ctx, path, &agents); err != nil {
|
|
return fmt.Errorf("list agents: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, agents)
|
|
}
|
|
|
|
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"}
|
|
rows := make([][]string, 0, len(agents))
|
|
for _, a := range agents {
|
|
archived := ""
|
|
if v := strVal(a, "archived_at"); v != "" {
|
|
archived = "yes"
|
|
}
|
|
rows = append(rows, []string{
|
|
strVal(a, "id"),
|
|
strVal(a, "name"),
|
|
strVal(a, "status"),
|
|
strVal(a, "runtime_mode"),
|
|
archived,
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
func runAgentGet(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var agent map[string]any
|
|
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
|
|
return fmt.Errorf("get agent: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, agent)
|
|
}
|
|
|
|
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "AVATAR_URL", "DESCRIPTION"}
|
|
rows := [][]string{{
|
|
strVal(agent, "id"),
|
|
strVal(agent, "name"),
|
|
strVal(agent, "status"),
|
|
strVal(agent, "runtime_mode"),
|
|
strVal(agent, "visibility"),
|
|
strVal(agent, "avatar_url"),
|
|
strVal(agent, "description"),
|
|
}}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
// applyAgentPermissionFlags translates the invocation-permission flags
|
|
// (--permission-mode / --public-to-workspace / --public-to-member) into the
|
|
// permission_mode + invocation_targets request fields (MUL-3963). When none of
|
|
// the flags are set it is a no-op, so the legacy --visibility handling still
|
|
// drives the request. When any public-to-* flag is present without an explicit
|
|
// --permission-mode, the mode defaults to public_to.
|
|
func applyAgentPermissionFlags(cmd *cobra.Command, body map[string]any) {
|
|
hasMode := cmd.Flags().Changed("permission-mode")
|
|
hasWorkspace := cmd.Flags().Changed("public-to-workspace")
|
|
hasMembers := cmd.Flags().Changed("public-to-member")
|
|
if !hasMode && !hasWorkspace && !hasMembers {
|
|
return
|
|
}
|
|
|
|
mode := "public_to"
|
|
if hasMode {
|
|
mode, _ = cmd.Flags().GetString("permission-mode")
|
|
}
|
|
body["permission_mode"] = mode
|
|
|
|
targets := []map[string]any{}
|
|
if on, _ := cmd.Flags().GetBool("public-to-workspace"); on {
|
|
targets = append(targets, map[string]any{"target_type": "workspace"})
|
|
}
|
|
if members, _ := cmd.Flags().GetStringSlice("public-to-member"); len(members) > 0 {
|
|
for _, m := range members {
|
|
targets = append(targets, map[string]any{"target_type": "member", "target_id": m})
|
|
}
|
|
}
|
|
body["invocation_targets"] = targets
|
|
}
|
|
|
|
func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
name, _ := cmd.Flags().GetString("name")
|
|
if name == "" {
|
|
return fmt.Errorf("--name is required")
|
|
}
|
|
runtimeID, _ := cmd.Flags().GetString("runtime-id")
|
|
if runtimeID == "" {
|
|
return fmt.Errorf("--runtime-id is required")
|
|
}
|
|
|
|
body := map[string]any{
|
|
"name": name,
|
|
"runtime_id": runtimeID,
|
|
}
|
|
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
|
body["description"] = v
|
|
}
|
|
if v, _ := cmd.Flags().GetString("instructions"); v != "" {
|
|
body["instructions"] = v
|
|
}
|
|
if cmd.Flags().Changed("runtime-config") {
|
|
v, _ := cmd.Flags().GetString("runtime-config")
|
|
var rc any
|
|
if err := json.Unmarshal([]byte(v), &rc); err != nil {
|
|
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
|
|
}
|
|
body["runtime_config"] = rc
|
|
}
|
|
if cmd.Flags().Changed("custom-args") {
|
|
v, _ := cmd.Flags().GetString("custom-args")
|
|
ca, err := parseCustomArgs(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body["custom_args"] = ca
|
|
}
|
|
if ce, ok, err := resolveCustomEnv(cmd); err != nil {
|
|
return err
|
|
} else if ok {
|
|
body["custom_env"] = ce
|
|
}
|
|
if mc, ok, err := resolveMcpConfig(cmd); err != nil {
|
|
return err
|
|
} else if ok {
|
|
body["mcp_config"] = mc
|
|
}
|
|
if cmd.Flags().Changed("model") {
|
|
v, _ := cmd.Flags().GetString("model")
|
|
body["model"] = v
|
|
}
|
|
// thinking_level mirrors model: a thin pass-through to the top-level agent
|
|
// field the server already accepts and validates (IsKnownThinkingValue).
|
|
// The CLI deliberately does not enumerate valid levels — they are
|
|
// runtime/model-specific and the server owns the catalog (MUL-2339).
|
|
if cmd.Flags().Changed("thinking-level") {
|
|
v, _ := cmd.Flags().GetString("thinking-level")
|
|
body["thinking_level"] = v
|
|
}
|
|
if cmd.Flags().Changed("visibility") {
|
|
v, _ := cmd.Flags().GetString("visibility")
|
|
body["visibility"] = v
|
|
}
|
|
applyAgentPermissionFlags(cmd, body)
|
|
if cmd.Flags().Changed("max-concurrent-tasks") {
|
|
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
|
body["max_concurrent_tasks"] = v
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result map[string]any
|
|
if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil {
|
|
return fmt.Errorf("create agent: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, result)
|
|
}
|
|
|
|
fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
|
return nil
|
|
}
|
|
|
|
func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body := map[string]any{}
|
|
if cmd.Flags().Changed("name") {
|
|
v, _ := cmd.Flags().GetString("name")
|
|
body["name"] = v
|
|
}
|
|
if cmd.Flags().Changed("description") {
|
|
v, _ := cmd.Flags().GetString("description")
|
|
body["description"] = v
|
|
}
|
|
if cmd.Flags().Changed("instructions") {
|
|
v, _ := cmd.Flags().GetString("instructions")
|
|
body["instructions"] = v
|
|
}
|
|
if cmd.Flags().Changed("runtime-id") {
|
|
v, _ := cmd.Flags().GetString("runtime-id")
|
|
body["runtime_id"] = v
|
|
}
|
|
if cmd.Flags().Changed("runtime-config") {
|
|
v, _ := cmd.Flags().GetString("runtime-config")
|
|
var rc any
|
|
if err := json.Unmarshal([]byte(v), &rc); err != nil {
|
|
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
|
|
}
|
|
body["runtime_config"] = rc
|
|
}
|
|
if cmd.Flags().Changed("custom-args") {
|
|
v, _ := cmd.Flags().GetString("custom-args")
|
|
ca, err := parseCustomArgs(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body["custom_args"] = ca
|
|
}
|
|
if cmd.Flags().Changed("model") {
|
|
v, _ := cmd.Flags().GetString("model")
|
|
body["model"] = v
|
|
}
|
|
// thinking_level is a tri-state on the server (omitted = no change, "" =
|
|
// clear to runtime default, value = set). Sending the key only when the
|
|
// flag was provided produces exactly that, the same way --model behaves.
|
|
if cmd.Flags().Changed("thinking-level") {
|
|
v, _ := cmd.Flags().GetString("thinking-level")
|
|
body["thinking_level"] = v
|
|
}
|
|
if cmd.Flags().Changed("visibility") {
|
|
v, _ := cmd.Flags().GetString("visibility")
|
|
body["visibility"] = v
|
|
}
|
|
applyAgentPermissionFlags(cmd, body)
|
|
if cmd.Flags().Changed("status") {
|
|
v, _ := cmd.Flags().GetString("status")
|
|
body["status"] = v
|
|
}
|
|
if cmd.Flags().Changed("max-concurrent-tasks") {
|
|
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
|
body["max_concurrent_tasks"] = v
|
|
}
|
|
if mc, ok, err := resolveMcpConfig(cmd); err != nil {
|
|
return err
|
|
} else if ok {
|
|
body["mcp_config"] = mc
|
|
}
|
|
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --thinking-level, --custom-args, --mcp-config, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result map[string]any
|
|
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil {
|
|
return fmt.Errorf("update agent: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, result)
|
|
}
|
|
|
|
fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
|
return nil
|
|
}
|
|
|
|
func runAgentArchive(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result map[string]any
|
|
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil {
|
|
return fmt.Errorf("archive agent: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, result)
|
|
}
|
|
|
|
fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
|
return nil
|
|
}
|
|
|
|
func runAgentRestore(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result map[string]any
|
|
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil {
|
|
return fmt.Errorf("restore agent: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, result)
|
|
}
|
|
|
|
fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
|
return nil
|
|
}
|
|
|
|
func runAgentTasks(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var tasks []map[string]any
|
|
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil {
|
|
return fmt.Errorf("list agent tasks: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, tasks)
|
|
}
|
|
|
|
headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"}
|
|
rows := make([][]string, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
rows = append(rows, []string{
|
|
strVal(t, "id"),
|
|
strVal(t, "issue_id"),
|
|
strVal(t, "status"),
|
|
strVal(t, "created_at"),
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
func runAgentAvatar(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filePath, _ := cmd.Flags().GetString("file")
|
|
if filePath == "" {
|
|
return fmt.Errorf("--file is required")
|
|
}
|
|
|
|
// Validate file exists.
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("file not found: %w", err)
|
|
}
|
|
|
|
// Validate extension.
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
validExts := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true}
|
|
if !validExts[ext] {
|
|
return fmt.Errorf("unsupported file format %q: must be .png, .jpg, .jpeg, .gif, or .webp", ext)
|
|
}
|
|
|
|
// Client-side size guard: reject files > 5MB.
|
|
const maxSize = 5 << 20 // 5 MB
|
|
if info.Size() > maxSize {
|
|
return fmt.Errorf("file too large: %d bytes (max 5MB)", info.Size())
|
|
}
|
|
|
|
fileData, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("read file: %w", err)
|
|
}
|
|
|
|
// Defensive re-check: guard against TOCTOU race where the file
|
|
// was swapped between stat and read.
|
|
if len(fileData) > maxSize {
|
|
return fmt.Errorf("file too large: %d bytes (max 5MB)", len(fileData))
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), cli.AtLeastAPITimeout(60*time.Second))
|
|
defer cancel()
|
|
|
|
// Agent existence pre-check.
|
|
var agent map[string]any
|
|
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
|
|
return fmt.Errorf("get agent: %w", err)
|
|
}
|
|
|
|
id, url, err := client.UploadFileWithURL(ctx, fileData, filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("upload avatar: %w", err)
|
|
}
|
|
|
|
body := map[string]any{"avatar_url": url}
|
|
var result map[string]any
|
|
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil {
|
|
return fmt.Errorf("update agent avatar: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, map[string]any{
|
|
"id": id,
|
|
"agent_id": args[0],
|
|
"avatar_url": url,
|
|
})
|
|
}
|
|
|
|
headers := []string{"ID", "AGENT_ID", "AVATAR_URL"}
|
|
rows := [][]string{{
|
|
id,
|
|
args[0],
|
|
url,
|
|
}}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent skills subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func runAgentSkillsList(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var skills []map[string]any
|
|
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil {
|
|
return fmt.Errorf("list agent skills: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, skills)
|
|
}
|
|
|
|
headers := []string{"ID", "NAME", "DESCRIPTION"}
|
|
rows := make([][]string, 0, len(skills))
|
|
for _, s := range skills {
|
|
rows = append(rows, []string{
|
|
strVal(s, "id"),
|
|
strVal(s, "name"),
|
|
strVal(s, "description"),
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
func runAgentSkillsSet(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !cmd.Flags().Changed("skill-ids") {
|
|
return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)")
|
|
}
|
|
cleanIDs := cleanSkillIDsFlag(cmd)
|
|
body := map[string]any{
|
|
"skill_ids": cleanIDs,
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result json.RawMessage
|
|
if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil {
|
|
return fmt.Errorf("set agent skills: %w", err)
|
|
}
|
|
|
|
return printAgentSkillsMutationResult(cmd, args[0], result)
|
|
}
|
|
|
|
func runAgentSkillsAdd(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !cmd.Flags().Changed("skill-ids") {
|
|
return fmt.Errorf("--skill-ids is required (comma-separated skill IDs)")
|
|
}
|
|
cleanIDs := cleanSkillIDsFlag(cmd)
|
|
if len(cleanIDs) == 0 {
|
|
return fmt.Errorf("--skill-ids must include at least one skill ID")
|
|
}
|
|
body := map[string]any{
|
|
"skill_ids": cleanIDs,
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result json.RawMessage
|
|
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/skills/add", body, &result); err != nil {
|
|
return fmt.Errorf("add agent skills: %w", err)
|
|
}
|
|
|
|
return printAgentSkillsMutationResult(cmd, args[0], result)
|
|
}
|
|
|
|
func cleanSkillIDsFlag(cmd *cobra.Command) []string {
|
|
skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids")
|
|
cleanIDs := make([]string, 0, len(skillIDs))
|
|
for _, id := range skillIDs {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
cleanIDs = append(cleanIDs, id)
|
|
}
|
|
}
|
|
return cleanIDs
|
|
}
|
|
|
|
func printAgentSkillsMutationResult(cmd *cobra.Command, agentID string, result json.RawMessage) error {
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
var pretty any
|
|
json.Unmarshal(result, &pretty)
|
|
return cli.PrintJSON(os.Stdout, pretty)
|
|
}
|
|
|
|
var skills []map[string]any
|
|
if err := json.Unmarshal(result, &skills); err != nil {
|
|
return fmt.Errorf("decode agent skills response: %w", err)
|
|
}
|
|
if len(skills) == 0 {
|
|
fmt.Printf("No skills assigned to agent %s\n", agentID)
|
|
return nil
|
|
}
|
|
headers := []string{"ID", "NAME", "DESCRIPTION"}
|
|
rows := make([][]string, 0, len(skills))
|
|
for _, s := range skills {
|
|
rows = append(rows, []string{
|
|
strVal(s, "id"),
|
|
strVal(s, "name"),
|
|
strVal(s, "description"),
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent env subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// runAgentEnvGet fetches the plaintext custom_env for a single agent
|
|
// via the audited `/env` endpoint. The CLI prints raw JSON in JSON
|
|
// mode and a key/value table otherwise; we never truncate or mask
|
|
// values here — the security gate is on the server, not the printer.
|
|
func runAgentEnvGet(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var resp map[string]any
|
|
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/env", &resp); err != nil {
|
|
return fmt.Errorf("get agent env: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, resp)
|
|
}
|
|
|
|
headers := []string{"KEY", "VALUE"}
|
|
env, _ := resp["custom_env"].(map[string]any)
|
|
rows := make([][]string, 0, len(env))
|
|
for k, v := range env {
|
|
rows = append(rows, []string{k, fmt.Sprintf("%v", v)})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
// runAgentEnvSet replaces an agent's custom_env wholesale via the
|
|
// audited `/env` endpoint. The three secret-safe input channels
|
|
// (--custom-env, --custom-env-stdin, --custom-env-file) are required
|
|
// — at least one must be supplied — and the server treats any value
|
|
// equal to "****" as "preserve the existing entry" (see the **** guard
|
|
// in the handler).
|
|
func runAgentEnvSet(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ce, ok, err := resolveCustomEnv(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("specify the new env via --custom-env, --custom-env-stdin, or --custom-env-file (pass '{}' to clear)")
|
|
}
|
|
|
|
body := map[string]any{"custom_env": ce}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var result map[string]any
|
|
if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/env", body, &result); err != nil {
|
|
return fmt.Errorf("update agent env: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, result)
|
|
}
|
|
|
|
env, _ := result["custom_env"].(map[string]any)
|
|
fmt.Printf("Env updated for agent %s (%d keys)\n", args[0], len(env))
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// parseCustomEnv parses the --custom-env flag value (a JSON object literal)
|
|
// into a string map suitable for the request body. The clear-all signal is
|
|
// the explicit JSON object "{}"; empty or whitespace-only input is rejected
|
|
// because for the stdin/file channels it almost always means an upstream
|
|
// failure (missing file, unset pipe, set -o pipefail off) rather than a
|
|
// deliberate clear. Treating it as "clear" silently wipes secrets.
|
|
//
|
|
// The payload is treated as secret material: parse errors never wrap the
|
|
// underlying json error, because json.SyntaxError / UnmarshalTypeError can
|
|
// surface short fragments of the input on some malformed inputs.
|
|
func parseCustomEnv(raw string) (map[string]string, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, fmt.Errorf("--custom-env: empty input; pass '{}' to clear")
|
|
}
|
|
var ce map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &ce); err != nil {
|
|
return nil, fmt.Errorf("--custom-env must be a valid JSON object of string keys and string values")
|
|
}
|
|
if ce == nil {
|
|
ce = map[string]string{}
|
|
}
|
|
return ce, nil
|
|
}
|
|
|
|
// parseCustomArgs parses the --custom-args flag value (a JSON array of
|
|
// CLI argument strings). The error message is content-free for the same
|
|
// reason as parseCustomEnv: although custom_args is not a dedicated
|
|
// secret channel today, it routinely carries values like "--api-key=…"
|
|
// for runtime providers, and json.Unmarshal errors can echo short
|
|
// fragments of malformed input.
|
|
func parseCustomArgs(raw string) ([]string, error) {
|
|
var ca []string
|
|
if err := json.Unmarshal([]byte(raw), &ca); err != nil {
|
|
return nil, fmt.Errorf("--custom-args must be a valid JSON array of strings")
|
|
}
|
|
return ca, nil
|
|
}
|
|
|
|
// resolveCustomEnv collects the --custom-env, --custom-env-stdin, and
|
|
// --custom-env-file flags and returns the parsed map, a bool indicating
|
|
// whether the caller supplied any of them, and any error. The three input
|
|
// channels are mutually exclusive so callers can't accidentally provide a
|
|
// secret twice. Stdin and file inputs exist to keep secret material out of
|
|
// shell history and 'ps' / /proc/<pid>/cmdline.
|
|
func resolveCustomEnv(cmd *cobra.Command) (map[string]string, bool, error) {
|
|
inline := cmd.Flags().Changed("custom-env")
|
|
fromStdin, _ := cmd.Flags().GetBool("custom-env-stdin")
|
|
filePath, _ := cmd.Flags().GetString("custom-env-file")
|
|
// Note: an explicit --custom-env-file "" is honored as "the user asked
|
|
// for this channel with an empty path" and surfaces a real error below,
|
|
// rather than being silently swallowed.
|
|
fromFile := cmd.Flags().Changed("custom-env-file")
|
|
|
|
count := 0
|
|
if inline {
|
|
count++
|
|
}
|
|
if fromStdin {
|
|
count++
|
|
}
|
|
if fromFile {
|
|
count++
|
|
}
|
|
switch {
|
|
case count == 0:
|
|
return nil, false, nil
|
|
case count > 1:
|
|
return nil, false, fmt.Errorf("--custom-env, --custom-env-stdin, and --custom-env-file are mutually exclusive; pick one")
|
|
}
|
|
|
|
var raw string
|
|
switch {
|
|
case inline:
|
|
raw, _ = cmd.Flags().GetString("custom-env")
|
|
case fromStdin:
|
|
buf, err := io.ReadAll(cmd.InOrStdin())
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("read --custom-env-stdin: %w", err)
|
|
}
|
|
raw = string(buf)
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, false, fmt.Errorf("--custom-env-stdin: empty input; pass '{}' to clear")
|
|
}
|
|
case fromFile:
|
|
if filePath == "" {
|
|
return nil, false, fmt.Errorf("--custom-env-file: path must not be empty")
|
|
}
|
|
buf, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
// Filesystem errors may include the path but not the contents —
|
|
// safe to surface via %w.
|
|
return nil, false, fmt.Errorf("read --custom-env-file: %w", err)
|
|
}
|
|
raw = string(buf)
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, false, fmt.Errorf("--custom-env-file %q: empty contents; pass '{}' to clear", filePath)
|
|
}
|
|
}
|
|
|
|
ce, err := parseCustomEnv(raw)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return ce, true, nil
|
|
}
|
|
|
|
// parseMcpConfig validates the --mcp-config value and returns the raw JSON to
|
|
// send. It accepts a JSON object (the MCP config, e.g. {"mcpServers": {…}}) or
|
|
// the literal `null` to clear the agent's config. A top-level array or
|
|
// primitive is rejected because it can never be a valid MCP config — this
|
|
// mirrors the agent-settings UI (mcp-config-tab.tsx). Empty/whitespace input
|
|
// is rejected rather than treated as a clear: for the stdin/file channels it
|
|
// almost always signals an upstream failure (missing file, unset pipe) rather
|
|
// than a deliberate clear, and silently wiping a secret-bearing field is the
|
|
// wrong default — pass an explicit `null` to clear.
|
|
//
|
|
// The payload is treated as secret material (MCP entries routinely carry API
|
|
// tokens), so parse errors never wrap the underlying json error, which can
|
|
// echo short fragments of malformed input.
|
|
func parseMcpConfig(raw string) (json.RawMessage, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return nil, fmt.Errorf("--mcp-config: empty input; pass 'null' to clear or a JSON object to set")
|
|
}
|
|
var probe any
|
|
if err := json.Unmarshal([]byte(trimmed), &probe); err != nil {
|
|
return nil, fmt.Errorf("--mcp-config must be a valid JSON object, or 'null' to clear")
|
|
}
|
|
// null → clear (NULL column server-side; on create it is a no-op).
|
|
if probe == nil {
|
|
return json.RawMessage("null"), nil
|
|
}
|
|
if _, ok := probe.(map[string]any); !ok {
|
|
return nil, fmt.Errorf("--mcp-config must be a JSON object, or 'null' to clear")
|
|
}
|
|
return json.RawMessage(trimmed), nil
|
|
}
|
|
|
|
// resolveMcpConfig collects the --mcp-config, --mcp-config-stdin, and
|
|
// --mcp-config-file flags and returns the raw JSON value to send, a bool
|
|
// indicating whether the caller supplied any of them, and any error. The
|
|
// three input channels are mutually exclusive so callers can't accidentally
|
|
// provide a secret twice. Stdin and file inputs exist to keep mcp_config —
|
|
// which routinely embeds API tokens — out of shell history and 'ps'. Mirrors
|
|
// resolveCustomEnv; the only behavioural difference is the clear sentinel
|
|
// (`null` here vs `{}` for custom_env), because mcp_config distinguishes an
|
|
// explicit empty object from an absent config server-side.
|
|
func resolveMcpConfig(cmd *cobra.Command) (json.RawMessage, bool, error) {
|
|
inline := cmd.Flags().Changed("mcp-config")
|
|
fromStdin, _ := cmd.Flags().GetBool("mcp-config-stdin")
|
|
filePath, _ := cmd.Flags().GetString("mcp-config-file")
|
|
fromFile := cmd.Flags().Changed("mcp-config-file")
|
|
|
|
count := 0
|
|
if inline {
|
|
count++
|
|
}
|
|
if fromStdin {
|
|
count++
|
|
}
|
|
if fromFile {
|
|
count++
|
|
}
|
|
switch {
|
|
case count == 0:
|
|
return nil, false, nil
|
|
case count > 1:
|
|
return nil, false, fmt.Errorf("--mcp-config, --mcp-config-stdin, and --mcp-config-file are mutually exclusive; pick one")
|
|
}
|
|
|
|
var raw string
|
|
switch {
|
|
case inline:
|
|
raw, _ = cmd.Flags().GetString("mcp-config")
|
|
case fromStdin:
|
|
buf, err := io.ReadAll(cmd.InOrStdin())
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("read --mcp-config-stdin: %w", err)
|
|
}
|
|
raw = string(buf)
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, false, fmt.Errorf("--mcp-config-stdin: empty input; pass 'null' to clear")
|
|
}
|
|
case fromFile:
|
|
if filePath == "" {
|
|
return nil, false, fmt.Errorf("--mcp-config-file: path must not be empty")
|
|
}
|
|
buf, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
// Filesystem errors may include the path but not the contents —
|
|
// safe to surface via %w.
|
|
return nil, false, fmt.Errorf("read --mcp-config-file: %w", err)
|
|
}
|
|
raw = string(buf)
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, false, fmt.Errorf("--mcp-config-file %q: empty contents; pass 'null' to clear", filePath)
|
|
}
|
|
}
|
|
|
|
mc, err := parseMcpConfig(raw)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return mc, true, nil
|
|
}
|
|
|
|
func strVal(m map[string]any, key string) string {
|
|
v, ok := m[key]
|
|
if !ok || v == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|