From c858adf6d0d026899933df5c6d4d844e8fb0be25 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 13 Sep 2026 16:16:57 +0000 Subject: [PATCH 1/2] feat(cli): add workspace management and MCP operations Adds workspace lifecycle commands backed by the upstream pinner workspace catalog. Exposes workspace list, create, get, attach, suspend, resume, access, and delete operations through the CLI and MCP surfaces. Uses pinner tip 17000af806af499c104f3c0bcaed00c3bc927d4e and removes the local module replacement. --- go.mod | 2 +- go.sum | 4 +- internal/cli/catalog_workspaces_wiring.go | 225 ++++++++++++++++++++++ internal/cli/command_registration_test.go | 2 + internal/cli/root.go | 1 + internal/cli/workspaces.go | 28 +++ internal/cli/workspaces_test.go | 85 ++++++++ internal/clicatalog/shapes_workspaces.go | 58 ++++++ internal/mcp/catalogassembly.go | 1 + internal/mcp/hostenv/surface.go | 7 + internal/mcp/materialization_test.go | 13 +- internal/mcp/workspaces_surface_test.go | 43 +++++ 12 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 internal/cli/catalog_workspaces_wiring.go create mode 100644 internal/cli/workspaces.go create mode 100644 internal/cli/workspaces_test.go create mode 100644 internal/clicatalog/shapes_workspaces.go create mode 100644 internal/mcp/workspaces_surface_test.go diff --git a/go.mod b/go.mod index 8a0f62bc..31b8bb3a 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( go.lumeweb.com/mcpplane v0.0.0-20260912095121-3753dd085aa8 go.lumeweb.com/oauth v0.1.6 go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 - go.lumeweb.com/pinner v0.0.0-20260913153654-28994179071e + go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af go.lumeweb.com/portal-sdk v0.1.73 go.lumeweb.com/queryutil v0.3.19 go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae diff --git a/go.sum b/go.sum index a9b51f4e..9558deaf 100644 --- a/go.sum +++ b/go.sum @@ -753,8 +753,8 @@ go.lumeweb.com/oauth v0.1.6 h1:6c6LrXxMwx5klbq3OshzXjkGwvYVjAQhjX2FxCAgqqg= go.lumeweb.com/oauth v0.1.6/go.mod h1:Bfdxi1gkv+Ypj9yj9uOSmKnbZX8C7q7Pyn1OdPyuCbM= go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 h1:kb2adBZ8Ed11yuXoQw2I8LfmbPjpPkc0NPwyShKJ5KE= go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9/go.mod h1:DbO27Lr6pBSzC+HogQMNNVEZMvkAHYwckL7i79aTETM= -go.lumeweb.com/pinner v0.0.0-20260913153654-28994179071e h1:g5+ZZKO0jgN8oHbumHw0tKxnqZVXpodyIYhPjsRIdgs= -go.lumeweb.com/pinner v0.0.0-20260913153654-28994179071e/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= +go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af h1:oi/hQ2cYZaKpl3SHGbPbVSj7waf0CcNKNIzPboaAvzY= +go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= go.lumeweb.com/portal v0.5.1 h1:l28uCNFmT+VewwRGhFwlmonYEwLxQfRh06hm7n0SQr8= go.lumeweb.com/portal v0.5.1/go.mod h1:JXy/eHHlUdXMsPbXSbRyX2ZM7s/OGNfSNsXH7XRYWCk= go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R737H7c= diff --git a/internal/cli/catalog_workspaces_wiring.go b/internal/cli/catalog_workspaces_wiring.go new file mode 100644 index 00000000..7960db44 --- /dev/null +++ b/internal/cli/catalog_workspaces_wiring.go @@ -0,0 +1,225 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" + ipfs "go.lumeweb.com/ipfs-sdk" + + opmesh "go.lumeweb.com/opmesh" + "go.lumeweb.com/pinner-cli/internal/clicatalog" + "go.lumeweb.com/pinner/catalogops" + "go.lumeweb.com/pinner/core/config" + "go.lumeweb.com/pinner/core/workspaces" +) + +// catalog_workspaces_wiring.go adapts the workspaces domain operations in +// internal/catalogops to the urfave CLI: it compiles the operations' command +// tree through the shape model in internal/clicatalog/shapes_workspaces.go and +// CompileCommandTree, renders each handler's result through the Output +// formatter, and maps the destructive --force gate onto operation inputs. IO +// and CLI concerns (the destructive force gate and sensitive credential +// rendering) live here, not in catalogops. +// +// Workspaces are deliberately a SEPARATE command tree from websites (an +// isolated runtime, not a domain-to-CID mapping): they mount as the top-level +// `workspaces` group (list/create/get/attach/suspend/resume/access/delete). +// Runtime workspace resolve is NOT exposed as a user operation. + +// catalogWorkspacesDeps builds the catalogops.WorkspacesDeps from the live CLI +// wiring. Service construction uses the core factories; all config is read +// lazily per invocation. +func catalogWorkspacesDeps(factory ...ConfigManagerFactory) catalogops.WorkspacesDeps { + cfgFactory := resolveConfigFactory(factory...) + return catalogops.WorkspacesDeps{ + CfgMgr: func() config.Manager { + cfgMgr, err := cfgFactory() + if err != nil { + return nil + } + return cfgMgr + }, + Secure: func() bool { + cfgMgr, err := cfgFactory() + if err != nil { + return false + } + return GetSecureSetting(nil, cfgMgr) + }, + ServiceFactory: workspaces.DefaultFactory, + NewAuthenticated: func(cfgMgr config.Manager, secure bool, token string) (workspaces.Service, error) { + return workspaces.NewAuthenticated(cfgMgr, token, secure) + }, + GetAuthToken: func() string { + cfgMgr, err := cfgFactory() + if err != nil { + return "" + } + return cfgMgr.Config().AuthToken + }, + } +} + +// workspacesCatalogDepsVar is an indirection so the wiring and the renderer +// can both reach the canonical operation list without rebuilding it repeatedly. +var workspacesCatalogDepsVar = catalogops.WorkspacesDeps(catalogWorkspacesDeps()) + +// newWorkspacesCatalogCommands compiles the workspaces catalog operations and +// returns the top-level "workspaces" subcommands they produce (list, create, +// get, attach, suspend, resume, access, delete). It declares the operations' +// command shape in internal/clicatalog/shapes_workspaces.go and materializes +// the tree through mount-owned leaf and parent builders. +func newWorkspacesCatalogCommands() []*cli.Command { + root := clicatalog.WorkspacesDomainRoot + ops := catalogops.WorkspacesOperations(workspacesCatalogDepsVar) + + cmds, err := clicatalog.CompileCommandTree( + ops, + clicatalog.WorkspacesShapes, + root, + workspacesCatalogConfig(), + buildWorkspacesLeaf, + buildCLIParent, + ) + if err != nil { + // Compilation of well-formed catalog operations cannot fail; if it + // does we must not silently skip the workspaces group. + panic(fmt.Sprintf("catalog compile workspaces: %v", err)) + } + return cmds +} + +// buildWorkspacesLeaf is the mount-owned leaf builder materializing one +// workspaces leaf into an urfave *cli.Command. Shape +// (name/category/aliases/flags/usage) comes from the clicatalog model via +// NewCLILeaf; behavior (the catalog action adapter) stays mount-owned here. +func buildWorkspacesLeaf(loc clicatalog.LeafLocator, cfg any) (*cli.Command, error) { + base, err := clicatalog.NewCLILeaf(loc.Op, loc.Name, loc.Category, loc.Aliases) + if err != nil { + return nil, err + } + relaxFlagRequired(base) + base.Action = catalogActionAdapter(loc.Op, cfg.(CatalogAdapterConfig)) + return base, nil +} + +// workspacesCatalogConfig returns the CatalogAdapterConfig that expresses the +// workspaces domain's exact per-invocation behavior on top of the shared +// catalogActionAdapter pipeline: the result renderer, the per-invocation +// --auth-token override, and the destructive --force gate (workspaces delete +// only). All remaining fields stay nil so the shared pipeline's safe defaults +// apply. +func workspacesCatalogConfig() CatalogAdapterConfig { + return CatalogAdapterConfig{ + Renderer: renderWorkspacesResult, + HonorAuthTokenOverride: true, + + // Destructive gate (workspaces delete). The shared pipeline enforces + // --force/--confirm; with a target workspace and no --force, refuse + // loudly (non-zero exit) with the "workspaces delete:" message. With + // no target (no "id" arg), fall through so the handler's required-arg + // validation produces a non-zero exit. + DestructiveGate: GateForceReject( + func(ic *CatalogInvokeContext) bool { + return opmesh.StrArg(ic.Input, "id", "") != "" + }, + func(ic *CatalogInvokeContext) string { + return "workspaces delete: pass --force to confirm this destructive operation" + }, + ), + } +} + +// renderWorkspacesResult is the catalog.RenderFunc that renders a workspaces +// handler's typed result through the CLI Output formatter. It is the single +// rendering home for catalog-driven workspaces commands. +func renderWorkspacesResult(_ context.Context, c *cli.Command, op opmesh.Operation, result any) error { + output := setupOutput(c) + + // Guard against a typed-nil single-object result (interface non-nil, + // underlying POINTER nil): a handler returning (nil, nil) yields a typed + // nil here, and the pointer branches below would dereference it and panic. + if result != nil && isNilPointerResult(result) { + return fmt.Errorf("%s returned no result", op.Name()) + } + + switch r := result.(type) { + case catalogops.ListResult: + return renderListResult(output, r) + + case *ipfs.WorkspaceResponse: + // workspaces get/create/attach/suspend/resume all return a workspace. + if output.IsJSON() { + return output.PrintJSON(r) + } + renderWorkspaceHuman(output, r) + return nil + + case *ipfs.WorkspaceAccessResponse: + // workspaces access. This carries SENSITIVE proxy Basic Auth + // credentials. Human output is rendered deliberately as labeled + // fields (never buried in a shared table/list). JSON output is the + // caller's explicit choice and prints the raw document. + if output.IsJSON() { + return output.PrintJSON(r) + } + renderWorkspaceAccessHuman(output, r) + return nil + + case *catalogops.WorkspaceDeleteResult: + if output.IsJSON() { + return output.PrintJSON(map[string]any{"success": true, "id": r.ID, "status": r.Status}) + } + output.Printfln("Workspace %s deleted successfully", r.ID) + return nil + + default: + if result == nil { + return nil + } + return fmt.Errorf("catalog command %q returned an unroutable result type %T", op.Name(), result) + } +} + +// renderWorkspaceHuman renders the fields of a single workspace (used by get, +// create, attach, suspend, resume). +func renderWorkspaceHuman(output Output, w *ipfs.WorkspaceResponse) { + output.Printfln("Workspace Details") + + fields := []Field{ + {"ID", fmt.Sprintf("%d", w.Id)}, + {"Domain", w.Domain}, + {"Label", w.Label}, + {"Status", w.Status}, + } + if w.WebsiteId != nil { + fields = append(fields, Field{"Website ID", fmt.Sprintf("%d", *w.WebsiteId)}) + } else { + fields = append(fields, Field{"Website ID", "-"}) + } + if w.Error != nil && *w.Error != "" { + fields = append(fields, Field{"Error", *w.Error}) + } + fields = append(fields, + Field{"Created", w.Created.Format("2006-01-02 15:04:05")}, + Field{"Updated", w.Updated.Format("2006-01-02 15:04:05")}, + ) + + output.PrintFields(FieldGroup{Fields: fields}) +} + +// renderWorkspaceAccessHuman renders the workspace proxy access credentials. +// These are SENSITIVE; the username/password are deliberately rendered as +// labeled fields on their own (the op is HumanOnly at the catalog layer, and +// a human explicitly invoking `workspaces access` is shown the credential +// deliberately rather than folded into a shared table/list). +func renderWorkspaceAccessHuman(output Output, r *ipfs.WorkspaceAccessResponse) { + output.Printfln("Workspace Proxy Access Credentials") + output.PrintFields(FieldGroup{ + Fields: []Field{ + {"Username", r.Username}, + {"Password", r.Password}, + }, + }) +} diff --git a/internal/cli/command_registration_test.go b/internal/cli/command_registration_test.go index 7635fe9a..ea40e1a8 100644 --- a/internal/cli/command_registration_test.go +++ b/internal/cli/command_registration_test.go @@ -127,6 +127,7 @@ func TestCommandRegistration_RootSubcommands(t *testing.T) { "dns", "ipns", "websites", + "workspaces", "dag", "export", "admin", @@ -173,6 +174,7 @@ func TestCommandRegistration_Categories(t *testing.T) { "dns": "Management", "ipns": "Management", "websites": "Management", + "workspaces": "Management", "config": "System", "doctor": "System", "bench": "System", diff --git a/internal/cli/root.go b/internal/cli/root.go index a27e5c44..ca72a08f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -104,6 +104,7 @@ For more help on any command: pinner --help`, newDNSCommand(), newIPNSCommand(), newWebsitesCommand(), + newWorkspacesCommand(), newDagCommand(), newExportCommand(), newAdminCommand(), diff --git a/internal/cli/workspaces.go b/internal/cli/workspaces.go new file mode 100644 index 00000000..1fb01b46 --- /dev/null +++ b/internal/cli/workspaces.go @@ -0,0 +1,28 @@ +package cli + +import "github.com/urfave/cli/v3" + +// newWorkspacesCommand mounts the workspaces domain as a top-level command +// tree. The parent is catalog-driven: the core workspace lifecycle +// subcommands (list, create, get, attach, suspend, resume, access, delete) are +// compiled from the canonical operation catalog (internal/catalogops) — see +// catalog_workspaces_wiring.go. +// +// Workspaces are deliberately a SEPARATE concept from websites (an isolated +// runtime with its own portal API key and proxy endpoint, not a +// domain-to-CID mapping), so they mount as their own `workspaces` tree rather +// than nesting under `websites`. To map domains to CIDs use the 'websites' +// command tree instead. +func newWorkspacesCommand() *cli.Command { + cmds := newWorkspacesCatalogCommands() + + return &cli.Command{ + Name: "workspaces", + Category: "Management", + Usage: "Manage workspaces", + Description: `Manage isolated workspaces: an isolated runtime with its own portal API key and proxy endpoint. Covers create/list/get, attach to a website you own (the publish link), suspend/resume its runtime, fetch proxy access credentials (access), and delete. + +Workspaces are a separate concept from websites. To associate domain names with CIDs so your IPFS/IPNS content is served over custom domains, use the 'websites' command tree instead. A website association is only an optional link on a workspace (attach), not what a workspace is for.`, + Commands: cmds, + } +} diff --git a/internal/cli/workspaces_test.go b/internal/cli/workspaces_test.go new file mode 100644 index 00000000..11221c97 --- /dev/null +++ b/internal/cli/workspaces_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lumeweb.com/opmesh" + + "go.lumeweb.com/pinner/catalogops" +) + +// TestWorkspacesCommandTree verifies the workspaces parent mounts exactly the +// expected catalog leaves (list/get/create/attach/suspend/resume/access/delete) +// and that workspaces sits at the top level (a SEPARATE tree from websites), +// not nested anywhere under it. +func TestWorkspacesCommandTree(t *testing.T) { + root := NewRootCommand() + ws := findCommand(root.Commands, "workspaces") + require.NotNil(t, ws, "workspaces command should exist at the root") + assert.Equal(t, "Management", ws.Category) + + names := commandNames(ws.Commands) + nameSet := make(map[string]bool, len(names)) + for _, n := range names { + nameSet[n] = true + } + for _, expected := range []string{"list", "get", "create", "attach", "suspend", "resume", "access", "delete"} { + assert.True(t, nameSet[expected], "workspaces should have subcommand %q", expected) + } + // Runtime resolve is intentionally not a user operation. + assert.False(t, nameSet["resolve"], "workspaces resolve must not be a user subcommand") +} + +// TestWorkspacesListHasLsAlias verifies the canonical list leaf carries the +// muscle-memory "ls" alias (ideal naming rule), matching other domains. +func TestWorkspacesListHasLsAlias(t *testing.T) { + ws := findCommand(NewRootCommand().Commands, "workspaces") + require.NotNil(t, ws) + list := findCommand(ws.Commands, "list") + require.NotNil(t, list, "workspaces list command should exist") + assert.Contains(t, list.Aliases, "ls", "workspaces list should have the 'ls' alias") +} + +// TestWorkspacesAccessIsHumanOnly verifies the sensitive-credential operation is +// gated to humans at the catalog layer (a model actor is handed off), while +// remaining discoverable (visibility both) — it is surfaced but never executed +// by an agent. +func TestWorkspacesAccessIsHumanOnly(t *testing.T) { + var access opmesh.Operation + for _, op := range catalogops.WorkspacesOperations(catalogops.WorkspacesDeps{}) { + if op.Name() == "workspaces_access" { + access = op + } + } + require.NotNil(t, access, "workspaces_access should be a catalog operation") + assert.Equal(t, opmesh.InteractionHumanOnly, access.Interaction()) + assert.Equal(t, opmesh.VisibilityBoth, access.Visibility()) +} + +// TestWorkspacesDeleteIsDestructive verifies the delete operation is declared +// destructive (so the shared pipeline enforces --force) and that the delete +// leaf renders the --force/--confirm flags. +func TestWorkspacesDeleteIsDestructive(t *testing.T) { + var del opmesh.Operation + for _, op := range catalogops.WorkspacesOperations(catalogops.WorkspacesDeps{}) { + if op.Name() == "workspaces_delete" { + del = op + } + } + require.NotNil(t, del, "workspaces_delete should be a catalog operation") + assert.Equal(t, opmesh.SafetyDestructive, del.Safety()) + + ws := findCommand(NewRootCommand().Commands, "workspaces") + require.NotNil(t, ws) + delCmd := findCommand(ws.Commands, "delete") + require.NotNil(t, delCmd) + hasForce := false + for _, f := range delCmd.Flags { + if f.Names()[0] == "force" || f.Names()[0] == "confirm" { + hasForce = true + } + } + assert.True(t, hasForce, "workspaces delete should expose a force/confirm flag") +} diff --git a/internal/clicatalog/shapes_workspaces.go b/internal/clicatalog/shapes_workspaces.go new file mode 100644 index 00000000..c72608f3 --- /dev/null +++ b/internal/clicatalog/shapes_workspaces.go @@ -0,0 +1,58 @@ +package clicatalog + +// shapes_workspaces.go declares the declarative command shape for the +// workspaces catalog domain, consumed by CompileCommandTree. +// +// Canonical op names (all-underscore, emitted by +// catalogops.WorkspacesOperations in a stable declaration order): +// +// workspaces_list/create/get/attach/suspend/resume/access/delete +// -> {"workspaces",} (flat leaves) +// +// Workspaces are a SEPARATE frontend/backend concept from websites (an +// isolated runtime, not a domain-to-CID mapping), so they mount as their own +// top-level `workspaces` command tree rather than nesting under `websites`. +// +// Name mapping: +// +// - The top-level workspaces_list renders as the canonical "list" leaf with +// the "ls" alias (ideal naming rule: list canonical, ls a muscle-memory +// alias), matching the websites/pins/domains leaves below. +// - workspaces_delete keeps the "delete" leaf (matching websites_delete; the +// operation ID is workspaces_delete and "delete" is a full verb). +// - The `workspaces access` leaf is gated HumanOnly at the op level; it is a +// deliberate, discoverable user operation (access credentials are +// sensitive and not surfaced to model actors), so it stays in this tree. +// - Runtime workspace resolve (workspaces_resolve) is deliberately NOT +// shipped: it is a runtime-internal concern (Coolify-injected resource +// UUID) and is not a normal user CLI/MCP operation. +// +// Order is left at 0 everywhere so tied siblings keep the registry-declaration +// (== WorkspacesOperations emission) order, per the model's deterministic +// (Order, declaration-order) sort. + +// WorkspacesShapes is the ShapeRegistry for the workspaces domain, keyed by +// the all-underscore canonical op Name. +var WorkspacesShapes = ShapeRegistry{ + "workspaces_list": { + Path: []string{"workspaces", "list"}, + Aliases: []string{"ls"}, + }, + "workspaces_create": {Path: []string{"workspaces", "create"}}, + "workspaces_get": {Path: []string{"workspaces", "get"}}, + "workspaces_attach": {Path: []string{"workspaces", "attach"}}, + "workspaces_suspend": {Path: []string{"workspaces", "suspend"}}, + "workspaces_resume": {Path: []string{"workspaces", "resume"}}, + "workspaces_access": {Path: []string{"workspaces", "access"}}, + "workspaces_delete": {Path: []string{"workspaces", "delete"}}, +} + +// WorkspacesDomainRoot is the DomainRoot declaration for the workspaces +// domain. The root command itself is constructed by the consuming CLI mount; +// the transform returns its ordered children (flat leaves). +var WorkspacesDomainRoot = DomainRoot{ + Name: "workspaces", + Category: "Management", + Usage: "Manage workspaces", + Desc: `Manage isolated workspaces: create/list/get, attach a workspace to a website you own (the publish link), suspend/resume its runtime, fetch proxy access credentials, and delete a workspace. Workspaces are a separate concept from websites (an isolated runtime with its own portal API key and proxy endpoint) — to map domains to CIDs use the 'websites' command tree instead. These subcommands are compiled from the canonical operation catalog (internal/catalogops).`, +} diff --git a/internal/mcp/catalogassembly.go b/internal/mcp/catalogassembly.go index d265712d..35081a5d 100644 --- a/internal/mcp/catalogassembly.go +++ b/internal/mcp/catalogassembly.go @@ -48,6 +48,7 @@ func AssembleCatalogOps(deps *CatalogDepsBundle, surface DomainScope, hosted boo VaultSetup: catalogops.VaultDeps(deps.VaultSetup), Pins: catalogops.PinsDeps(deps.Pins), Websites: catalogops.WebsitesDeps(deps.Websites), + Workspaces: catalogops.WorkspacesDeps(deps.Workspaces), DNS: catalogops.DNSDeps(deps.DNS), IPNS: catalogops.IPNSDeps(deps.IPNS), ENS: catalogops.ENSDeps(deps.ENS), diff --git a/internal/mcp/hostenv/surface.go b/internal/mcp/hostenv/surface.go index 95a04841..09ddff6a 100644 --- a/internal/mcp/hostenv/surface.go +++ b/internal/mcp/hostenv/surface.go @@ -25,6 +25,8 @@ type DomainScope struct { Pins bool // Websites enables IPFS website publishing operations. Websites bool + // Workspaces enables the isolated workspace/runtime operations. + Workspaces bool // DNS enables the DNS zone/record operations. DNS bool // IPNS enables the IPNS key/publish operations. @@ -45,6 +47,7 @@ var FullDomainScope = DomainScope{ Vault: true, Pins: true, Websites: true, + Workspaces: true, DNS: true, IPNS: true, ENS: true, @@ -61,6 +64,7 @@ var HostedDomainScope = DomainScope{ Account: true, Pins: true, Websites: true, + Workspaces: true, DNS: true, IPNS: true, ENS: true, @@ -96,6 +100,9 @@ func (s DomainScope) PinsOn() bool { return s.flagOn(s.Pins) } // WebsitesOn reports whether the website-publishing surface is enabled. func (s DomainScope) WebsitesOn() bool { return s.flagOn(s.Websites) } +// WorkspacesOn reports whether the workspace surface is enabled. +func (s DomainScope) WorkspacesOn() bool { return s.flagOn(s.Workspaces) } + // DNSOn reports whether the DNS surface is enabled. func (s DomainScope) DNSOn() bool { return s.flagOn(s.DNS) } diff --git a/internal/mcp/materialization_test.go b/internal/mcp/materialization_test.go index 657946ec..4d6c1233 100644 --- a/internal/mcp/materialization_test.go +++ b/internal/mcp/materialization_test.go @@ -307,13 +307,18 @@ func TestFlatStrategyIndependentOfHosted(t *testing.T) { require.Falsef(t, hostedPresent[n], "hosted surface must not materialize vault op %q", n) } - // Flat hosted: exactly the hosted catalog is direct; no vault, no meta - // (default). + // Flat hosted: exactly the agent-safe hosted catalog is direct; no vault, + // no meta (default). The sensitive-credential workspaces_access op is + // HumanOnly, so it stays gated (behind the needs_human meta gate) exactly + // as on the full surface; the remaining agent-safe hosted ops materialize + // directly. + hostedFlatCapable := flatCapableNames(t, HostedDomainScope, true) hostedFlat, _ := materializedNames(t, buildStrategyServer(t, HostedDomainScope, true, ListingFlat, false)) - require.Equal(t, len(hostedPresent), len(hostedFlat), "flat hosted must equal the hosted catalog exactly") - for n := range hostedPresent { + require.Equal(t, len(hostedFlatCapable), len(hostedFlat), "flat hosted must equal the agent-safe hosted catalog exactly") + for n := range hostedFlatCapable { require.Truef(t, hostedFlat[n], "flat hosted must materialize %q", n) } + require.False(t, hostedFlat["workspaces_access"], "flat hosted must keep HumanOnly workspaces_access gated (needs_human), not direct") require.False(t, hostedFlat["vault_status"], "flat hosted must not surface a vault op (surface-disabled)") // Full flat materializes the vault domain the hosted catalog excludes, diff --git a/internal/mcp/workspaces_surface_test.go b/internal/mcp/workspaces_surface_test.go new file mode 100644 index 00000000..514488c2 --- /dev/null +++ b/internal/mcp/workspaces_surface_test.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lumeweb.com/opmesh" + + "go.lumeweb.com/pinner/catalogops" +) + +// Verify the 8 workspaces_* lifecycle ops are registered in +// WorkspacesOperations (the source AssembleCatalogOps feeds from for the MCP +// surface), that runtime resolve is deliberately absent, and that the +// sensitive-credential op is gated to humans. +func TestWorkspacesOpsRegisteredInMCPSurface(t *testing.T) { + ops := catalogops.WorkspacesOperations(catalogops.WorkspacesDeps{}) + byName := map[string]opmesh.Operation{} + for _, op := range ops { + byName[op.Name()] = op + } + for _, want := range []string{ + "workspaces_list", + "workspaces_create", + "workspaces_get", + "workspaces_attach", + "workspaces_suspend", + "workspaces_resume", + "workspaces_access", + "workspaces_delete", + } { + require.True(t, byName[want] != nil, "MCP surface should expose %s", want) + } + _, resolve := byName["workspaces_resolve"] + assert.False(t, resolve, "runtime workspace resolve must not surface as an MCP operation") + + // The creds op is discoverable (visibility both) but human-gated. + access, ok := byName["workspaces_access"] + require.True(t, ok) + assert.Equal(t, opmesh.InteractionHumanOnly, access.Interaction()) + assert.Equal(t, opmesh.VisibilityBoth, access.Visibility()) +} From 00228b98c4c74559cf2da61d1559cd4b053acfb2 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 13 Sep 2026 16:23:45 +0000 Subject: [PATCH 2/2] fix(cli): wire workspaces deps into production MCP catalog bundle buildCatalogOpsDeps populated every catalogops domain except Workspaces, leaving deps.Workspaces as a zero WorkspacesDeps{} (all-nil service functions). The MCP assembly threads deps.Workspaces and the workspaces domain is enabled on the surface, so every workspaces_* operation was advertised yet failed at invocation with a service-unavailable error. Mirror the Websites line by wiring catalogWorkspacesDeps into the bundle. Add a regression test pinning that the workspaces domain assembles into the production MCP surface built from buildCatalogOpsDeps. --- internal/cli/catalog_deps.go | 1 + internal/cli/catalog_deps_test.go | 40 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/cli/catalog_deps.go b/internal/cli/catalog_deps.go index 5b148efd..b600e830 100644 --- a/internal/cli/catalog_deps.go +++ b/internal/cli/catalog_deps.go @@ -90,6 +90,7 @@ func buildCatalogOpsDeps(factory ...ConfigManagerFactory) *mcpadapter.CatalogDep VaultSetup: catalogops.VaultDeps(vaultSetupDeps), Pins: catalogops.PinsDeps(catalogPinningDeps(cfgFactory)), Websites: catalogops.WebsitesDeps(catalogWebsitesDeps(cfgFactory)), + Workspaces: catalogops.WorkspacesDeps(catalogWorkspacesDeps(cfgFactory)), DNS: catalogops.DNSDeps(catalogDNSDeps(cfgFactory)), IPNS: catalogops.IPNSDeps(catalogIPNSDeps(cfgFactory)), ENS: catalogops.ENSDeps(catalogENSDeps(cfgFactory)), diff --git a/internal/cli/catalog_deps_test.go b/internal/cli/catalog_deps_test.go index 2d94b325..c01b7864 100644 --- a/internal/cli/catalog_deps_test.go +++ b/internal/cli/catalog_deps_test.go @@ -89,6 +89,46 @@ func TestProductionCatalogOpsBundleExposesAdminDomain(t *testing.T) { } } +// TestProductionCatalogOpsBundleExposesWorkspacesDomain pins that the +// workspaces domain is wired into the production MCP surface assembled from +// buildCatalogOpsDeps (the bundle handed to the server via WithCatalogOps). +// Regression for a Kody finding: buildCatalogOpsDeps threaded everything except +// Workspaces, so the workspaces_* ops were advertised on the surface but ran +// against a zero WorkspacesDeps{} (all-nil service functions) and failed with +// "service unavailable" at invocation. +func TestProductionCatalogOpsBundleExposesWorkspacesDomain(t *testing.T) { + cfgMgr := configmocks.NewMockManager(t) + prev := configManagerFactory + configManagerFactory = func() (config.Manager, error) { return cfgMgr, nil } + defer func() { configManagerFactory = prev }() + + bundle := buildCatalogOpsDeps() + oc, err := mcpadapter.AssembleCatalogOps(bundle, mcpadapter.FullDomainScope, false) + require.NoError(t, err, "production deps must assemble a catalog") + + descs, err := catalogmcp.NewCompiler().Compile(oc) + require.NoError(t, err) + + names := map[string]bool{} + for _, d := range descs { + names[d.Name] = true + } + for _, want := range []string{ + "workspaces_list", + "workspaces_create", + "workspaces_get", + "workspaces_attach", + "workspaces_suspend", + "workspaces_resume", + "workspaces_access", + "workspaces_delete", + } { + if !names[want] { + t.Fatalf("production surface missing workspaces tool %q", want) + } + } +} + // domainPrefix returns the leading domain token of a dotted operation name. func domainPrefix(name string) string { for i := 0; i < len(name); i++ {