design cc marketplace deploy - sinrimin/harness-nexus GitHub Wiki

claude-code marketplace deploy β€” a one-click deploy method

Issue #6 Β· shipped 2026-09-22 Β· daemon 0.25.0-i6

Problem

Since Phase 3.5, claude-code profiles install through the CC-native plugin marketplace emitter β€” the ONLY path was manual: create a marketplace-scoped PAT on the Tokens page, then run claude plugin marketplace add … + claude plugin install <profile>@harness-nexus-<user> on the machine. The machine page's Deploy tab filtered profiles to DEPLOYABLE_TARGETS = [hermes, codex, deepseek, pi], so a claude-code profile (e.g. cc-profile) showed no deployable entry at all. This issue turns those two commands into a deploy method: one click from the Deploy tab, install-or-update, riding the unchanged C4 job pipeline (queue / replay / progress / result).

Ground truth (CC CLI 2.1.263, rig-verified 2026-09-22)

  • claude plugin marketplace add <url> is an idempotent no-op when the marketplace name exists ("already on disk β€” declared in user settings") β€” but it does not refresh a changed source URL. After a token rotation the stale URL wedges marketplace update until the marketplace is removed and re-added.
  • claude plugin marketplace update <name> refreshes the catalog (rc=0).
  • claude plugin install <p>@<mp> -y is a no-op success when already installed; it does NOT pull a newer version. -y is required for non-TTY β€” the daemon always passes it.
  • claude plugin update <p>@<mp> -y pulls the new version (verified 1.0.0 β†’ 1.0.1); already-latest is rc=0.
  • CC's state is readable JSON, so no CLI-text parsing anywhere:
    • ~/.claude/plugins/known_marketplaces.json β€” {name: {source: {source:'url', url}, installLocation, lastUpdated}}
    • ~/.claude/plugins/installed_plugins.json β€” {version: 2, plugins: {"p@mp": [{scope, installPath, version, …}]}}
  • CC decides updates by version number. The emitter writes profile.version into plugin.json and the marketplace entry, so content-only changes with a frozen version are invisible (#18 made this moot: versions are auto-numbered β€” decimal, carry at 16 β€” and bump exactly when the profile's entries change; the web no longer offers a manual version field) ("already at the latest version"). A version bump IS the publish act.

Design

Wire

POST /api/machines/:id/jobs {profileId} no longer 409s for target === 'claude-code'. The job stays type: 'deploy' but its payload carries a marketplace arm (shared marketplaceDeployArmSchema):

{
  "profileId": "…",
  "marketplace": {
    "baseUrl": "https://106.54.210.46:15921",   // PUBLIC_BASE_URL
    "marketplaceName": "harness-nexus-ashen",   // harness-nexus-<owner username>
    "pluginName": "cc-profile"                  // the profile's name
  }
}

The daemon's runDeploy parses the payload with deployJobPayloadSchema and branches on the arm to the new executor (cli/src/daemon/cc-marketplace.ts); everything else keeps the 3.3 adapter pipeline untouched.

Gates (route, modules/jobs.ts):

  • Soft capability gate: an ONLINE daemon without marketplace-deploy β†’ 409 DAEMON_NO_MARKETPLACE_DEPLOY (offline may queue, the deploy/harness rule). Daemon 0.25.0-i6 advertises the capability.
  • Owner-marketplace visibility: the daemon reads the catalog as the MACHINE OWNER (see tokens below), so the profile must be global or owned by the machine owner β€” otherwise 409 PROFILE_NOT_IN_OWNER_MARKETPLACE (an admin deploying their own personal profile onto a user's machine would emit a catalog that never contains it).

Result (deployResultDataSchema, additive): method: 'marketplace' + installedVersion (what CC itself recorded in installed_plugins.json); directory is ~/.claude/plugins. No AgentInstance is registered β€” JobService.onResult skips registerInstance when method === 'marketplace'. Chat keys off the runtime-detected instance (9 W1); a plugin install is not a runtime install, and a deploy row would falsely suggest the platform owns that runtime's config.

Tokens: the emitter accepts machine-ctl

The URL the daemon adds is ${baseUrl}/api/marketplace/<MACHINE PAT>/marketplace.json β€” the daemon uses the token it already holds in ~/.hnx/config.json; no secret rides the job payload or the job row. resolveTokenUser (modules/marketplace.ts) now accepts both marketplace-scoped PATs (the manual flow) and machine-ctl PATs (resolve to the machine's owner). Security posture:

  • The REST auth hook still REJECTS machine tokens β€” blast radius stays the two emitter GET routes.
  • A machine PAT already grants full /ctl control of the owner's machine; reading the owner's marketplace is a strictly weaker capability.
  • CC persists the URL inside ~/.claude β€” the token already lives on that same machine in ~/.hnx/config.json (0600); equivalent exposure.

The archive URLs inside the catalog carry the same token (the emitter embeds it), so plugin install/update fetches authenticate identically.

Executor (cli/src/daemon/cc-marketplace.ts)

Reuses the harness-job command pattern: inherits the daemon env (NODE_EXTRA_CA_CERTS and proxy vars must reach the claude CLI), 10-min per-command timeout with SIGTERM→SIGKILL, throttled stdout/stderr tail into job:progress. Sequence:

  1. Read known_marketplaces.json; same name but different URL (token rotation, PUBLIC_BASE_URL change) β†’ claude plugin marketplace remove first (rig-verified live: the pre-#6 manual marketplace used the user's marketplace PAT; the first one-click deploy removed and re-added under the machine PAT).
  2. claude plugin marketplace add <url> (idempotent).
  3. claude plugin marketplace update <name>.
  4. Read installed_plugins.json; installed β†’ claude plugin update <p>@<mp> -y, else claude plugin install <p>@<mp> -y.
  5. Re-read installed_plugins.json β†’ installedVersion in the result.

One-click update = the same deploy again. Bump the profile version first (see below) β€” that is the only thing that makes CC see a new build.

profile.version becomes editable

The blocker: version was hardcoded '1.0.0' at create and absent from updateProfileSchema β€” a marketplace update was IMPOSSIBLE to publish. Now PATCH /api/profiles/:id accepts version (min(1).max(64)), and the web Profiles page gained an Edit dialog (name / description / version + the entry checkboxes β€” MCP servers and per-kind resources, preselected from the stored rows; target stays immutable). Saving rebuilds entries from the checkbox state and carries pinnedVersion/installOptions over for still-selected ids; entries whose target no longer appears in the caller's lists drop out β€” saving self-heals dead references instead of 409-ing forever. The create dialog shares the same picker component (EntryPickers + useEntryLists + buildEntries in Profiles.tsx). Row menu β†’ Edit; the row itself shows the current v<version> badge.

Web

  • Deploy tab: the profile picker now includes claude-code profiles, labeled claude-code Β· marketplace vs the bare target for adapter deploys. The job table's Detail cell renders marketplace Β· <plugin> v<version> for succeeded marketplace deploys (jobDetailText).
  • Strings: machineDetail.viaMarketplace (en marketplace / zh εΈ‚εœΊ), updated deployDesc/noDeployable; profiles.editTitle/editDesc/ versionLabel/versionHint/updated (en/zh).

Environment requirements (unchanged from 3.5)

CC enforces https:// + non-loopback origins on archive sources (CLI β‰₯ 2.1.224): PUBLIC_BASE_URL must be the public HTTPS origin, and the machine must trust its CA (NODE_EXTRA_CA_CERTS β€” inherited by the executor from the daemon env). The rig already runs this shape (Caddy on :15921). claude must be on the daemon's PATH.

Rig verification (2026-09-22)

  • One-click deploy of cc-profile (2 entries: 1 MCP + 1 skill): job succeeded, installedVersion 1.0.0, claude plugin list shows cc-profile@harness-nexus-ashen βœ” enabled; the stale-URL remove+add path fired for real (user-PAT URL β†’ machine-PAT URL).
  • One-click update: PATCH version 1.1.0 β†’ same deploy β†’ plugin update ran, CC reports 1.1.0.
  • The installed zip's .mcp.json is the C2 client-mode shape (hnx mcp serve --profile <id> --server <https base>, no PAT, no inline secrets); skills land under the plugin's skills/.
  • Leak scan clean (executor spawns are short-lived synchronous children).
  • No AgentInstance rows created for marketplace deploys (verified).

Tests

  • server/test/jobs.test.ts β€” new describe: dispatch carries the arm (baseUrl/name/plugin) + no instance registration; emitter accepts the machine PAT (catalog + archive URLs carry it) and 404s unknown tokens; foreign-personal profile β†’ PROFILE_NOT_IN_OWNER_MARKETPLACE; PATCH version works + target still TARGET_IMMUTABLE. The old claude-codeβ†’TARGET_NOT_DEPLOYABLE gate test flipped to DAEMON_NO_MARKETPLACE_DEPLOY.
  • cli/test/cc-marketplace.test.ts β€” fake claude shim on PATH recording argv + maintaining CC's state JSONs: fresh install, update routing, stale-URL remove-before-add, subcommand failure settles the job failed.
  • scripts/smoke.mjs β€” [#6] section: full E2E through a REAL daemon dist with the shim (accepted β†’ succeeded method/installedVersion β†’ machine PAT in the add URL β†’ emitter serves the machine PAT β†’ second job routes to plugin update β†’ no agent instance). 434 passed / 0 failed.

Out of scope / follow-ups

  • Platform-side uninstall (claude plugin uninstall) β€” CC's own /plugin surface manages it; a row action could wrap it later.
  • Inventory/AgentInstance linkage for marketplace deploys.
  • opencode (no profile install adapter β€” the W12 stance is unchanged).
⚠️ **GitHub.com Fallback** ⚠️