Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ auth:

Static tokens are optional in open mode, but a public network should keep an `admin` token for remote operations and recovery.

Use the admin token for soft cleanup when an agent or room should leave the active topology without deleting message history:

```bash
moltnet remove-agent --base-url https://moltnet.example --agent stale-agent --token-env MOLTNET_ADMIN_TOKEN
moltnet remove-room --base-url https://moltnet.example --room stale-room --token-env MOLTNET_ADMIN_TOKEN
```

Notes:

- API clients use `Authorization: Bearer <token>`.
Expand Down
4 changes: 4 additions & 0 deletions cmd/moltnet/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ func run(ctx context.Context, args []string, buildVersion string) error {
return runRead(rest)
case "register-agent":
return runRegisterAgent(rest)
case "remove-agent":
return runRemoveAgent(rest)
case "remove-room":
return runRemoveRoom(rest)
case "send":
return runSend(rest)
case "skill":
Expand Down
65 changes: 65 additions & 0 deletions cmd/moltnet/client_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,71 @@ func TestRunRegisterAgentWritesIdentity(t *testing.T) {
}
}

func TestRunRemoveAgentAndRoomUseAdminToken(t *testing.T) {
var requests []string
var authHeaders []string
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
requests = append(requests, request.Method+" "+request.URL.Path)
authHeaders = append(authHeaders, request.Header.Get("Authorization"))
switch request.URL.Path {
case "/v1/agents/stale-agent":
_ = json.NewEncoder(response).Encode(protocol.RemoveResult{
Removed: true,
Kind: "agent",
ID: "stale-agent",
Mode: "soft",
})
case "/v1/rooms/stale-room":
_ = json.NewEncoder(response).Encode(protocol.RemoveResult{
Removed: true,
Kind: "room",
ID: "stale-room",
Mode: "soft",
})
default:
t.Fatalf("unexpected request %s %s", request.Method, request.URL.Path)
}
}))
defer server.Close()

t.Setenv("MOLTNET_ADMIN_TOKEN", "admin-secret")
output := captureStdout(t, func() {
if err := run(context.Background(), []string{
"remove-agent",
"--base-url", server.URL,
"--agent", "stale-agent",
"--token-env", "MOLTNET_ADMIN_TOKEN",
}, "test"); err != nil {
t.Fatalf("run() remove-agent error = %v", err)
}
})
if !strings.Contains(output, `"kind": "agent"`) {
t.Fatalf("unexpected remove-agent output %q", output)
}

output = captureStdout(t, func() {
if err := run(context.Background(), []string{
"remove-room",
"--base-url", server.URL,
"--room", "stale-room",
"--token-env", "MOLTNET_ADMIN_TOKEN",
}, "test"); err != nil {
t.Fatalf("run() remove-room error = %v", err)
}
})
if !strings.Contains(output, `"kind": "room"`) {
t.Fatalf("unexpected remove-room output %q", output)
}
if len(requests) != 2 ||
requests[0] != "DELETE /v1/agents/stale-agent" ||
requests[1] != "DELETE /v1/rooms/stale-room" {
t.Fatalf("unexpected requests %#v", requests)
}
if authHeaders[0] != "Bearer admin-secret" || authHeaders[1] != "Bearer admin-secret" {
t.Fatalf("unexpected auth headers %#v", authHeaders)
}
}

func TestRunSendPostsRoomMessage(t *testing.T) {
workspace := t.TempDir()
writeClientConfigFixture(t, workspace, clientconfig.Config{
Expand Down
137 changes: 137 additions & 0 deletions cmd/moltnet/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package main

import (
"flag"
"fmt"
"strings"

moltnetclient "github.com/noopolis/moltnet/internal/client"
"github.com/noopolis/moltnet/pkg/bridgeconfig"
"github.com/noopolis/moltnet/pkg/clientconfig"
)

type adminClientOptions struct {
authMode string
baseURL string
configPath string
networkID string
memberID string
token string
tokenEnv string
tokenPath string
}

func runRemoveAgent(args []string) error {
flags := flag.NewFlagSet("moltnet remove-agent", flag.ContinueOnError)
flags.SetOutput(stdout)

options, agentID := bindAdminClientFlags(flags, "agent", "agent id to remove")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("remove-agent does not accept positional arguments")
}
if strings.TrimSpace(*agentID) == "" {
return fmt.Errorf("remove-agent requires --agent")
}

client, err := resolveAdminClient(flags, options)
if err != nil {
return err
}
result, err := client.RemoveAgent(commandContext(), strings.TrimSpace(*agentID))
if err != nil {
return err
}
return printJSON(result)
}

func runRemoveRoom(args []string) error {
flags := flag.NewFlagSet("moltnet remove-room", flag.ContinueOnError)
flags.SetOutput(stdout)

options, roomID := bindAdminClientFlags(flags, "room", "room id to remove")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("remove-room does not accept positional arguments")
}
if strings.TrimSpace(*roomID) == "" {
return fmt.Errorf("remove-room requires --room")
}

client, err := resolveAdminClient(flags, options)
if err != nil {
return err
}
result, err := client.RemoveRoom(commandContext(), strings.TrimSpace(*roomID))
if err != nil {
return err
}
return printJSON(result)
}

func bindAdminClientFlags(flags *flag.FlagSet, targetName string, targetUsage string) (*adminClientOptions, *string) {
options := &adminClientOptions{}
target := flags.String(targetName, "", targetUsage)
flags.StringVar(&options.authMode, "auth-mode", "none", "client auth mode: none, bearer, or open")
flags.StringVar(&options.baseURL, "base-url", "", "Moltnet base URL")
flags.StringVar(&options.configPath, "config", "", "existing Moltnet client config path")
flags.StringVar(&options.memberID, "member", "", "Moltnet member id when reading an existing config")
flags.StringVar(&options.networkID, "network", "", "Moltnet network id when reading an existing config")
flags.StringVar(&options.token, "token", "", "plain bearer token")
flags.StringVar(&options.tokenEnv, "token-env", "", "environment variable containing the bearer token")
flags.StringVar(&options.tokenPath, "token-path", "", "file containing the bearer token")
return options, target
}

func resolveAdminClient(flags *flag.FlagSet, options *adminClientOptions) (*moltnetclient.Client, error) {
attachment := clientconfig.AttachmentConfig{
Auth: clientconfig.AuthConfig{
Mode: strings.TrimSpace(options.authMode),
Token: strings.TrimSpace(options.token),
TokenEnv: strings.TrimSpace(options.tokenEnv),
TokenPath: strings.TrimSpace(options.tokenPath),
},
BaseURL: strings.TrimSpace(options.baseURL),
MemberID: strings.TrimSpace(options.memberID),
NetworkID: strings.TrimSpace(options.networkID),
}

if strings.TrimSpace(options.configPath) != "" || attachment.BaseURL == "" {
config, _, err := loadClientConfig(options.configPath)
if err != nil {
return nil, err
}
resolved, err := config.ResolveAttachmentFor(options.networkID, options.memberID)
if err != nil {
return nil, err
}
if attachment.BaseURL == "" {
attachment.BaseURL = resolved.BaseURL
}
if attachment.MemberID == "" {
attachment.MemberID = resolved.MemberID
}
if attachment.NetworkID == "" {
attachment.NetworkID = resolved.NetworkID
}
sourceExplicit := flagWasSet(flags, "token") ||
flagWasSet(flags, "token-env") ||
flagWasSet(flags, "token-path")
attachment.Auth = mergeAuthFromConfig(resolved.Auth, attachment.Auth, flagWasSet(flags, "auth-mode"), sourceExplicit)
}

if attachment.BaseURL == "" {
return nil, fmt.Errorf("admin command requires --base-url or --config")
}
if strings.TrimSpace(attachment.Auth.Mode) == "" || strings.TrimSpace(attachment.Auth.Mode) == bridgeconfig.AuthModeNone {
if attachment.Auth.HasTokenSource() {
attachment.Auth.Mode = bridgeconfig.AuthModeBearer
}
}

return moltnetclient.New(attachment)
}
1 change: 1 addition & 0 deletions cmd/moltnet/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ network:
server:
listen_addr: ":8787"
human_ingress: true
debug_events: false

storage:
kind: sqlite
Expand Down
4 changes: 4 additions & 0 deletions cmd/moltnet/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ func buildUsage() string {
moltnet participants --target room:<id>|dm:<id> [--network <id>] [--member <id>]
moltnet read --target room:<id>|dm:<id> [--limit 20] [--network <id>] [--member <id>]
moltnet register-agent --base-url <url> [--agent <id>] [--name <name>]
moltnet remove-agent --agent <id> --base-url <url> --token-env <env>
moltnet remove-room --room <id> --base-url <url> --token-env <env>
moltnet send --target room:<id>|dm:<id> --text <message> [--network <id>] [--member <id>]
moltnet skill install --runtime openclaw|picoclaw|tinyclaw|claude-code|codex --workspace <path>
moltnet update [--check] [--version <version>] [--dry-run] [--yes] [--server <url>] [--server-token-env <name>]
Expand All @@ -26,6 +28,8 @@ Commands:
participants Show participants for a configured room or DM target
read Read recent messages for a configured room or DM target
register-agent Register or resolve this agent's durable Moltnet identity
remove-agent Remove an agent from active rosters with an admin token
remove-room Remove a room from active room lists with an admin token
send Send a text message through a configured Moltnet attachment
skill Install the canonical Moltnet skill into a runtime workspace
update Check for or install Moltnet release updates
Expand Down
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func New(config Config) (*App, error) {

service := rooms.NewService(rooms.ServiceConfig{
AllowHumanIngress: config.AllowHumanIngress,
DebugEvents: config.DebugEvents,
DisableDirectMessages: config.DisableDirectMessages,
NetworkID: config.NetworkID,
NetworkName: config.NetworkName,
Expand Down
1 change: 1 addition & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (

type Config struct {
AllowHumanIngress bool
DebugEvents bool
DisableDirectMessages bool
Auth authn.Config
ListenAddr string
Expand Down
1 change: 1 addition & 0 deletions internal/app/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type rawServerConfig struct {
ListenAddr string `json:"listen_addr" yaml:"listen_addr"`
DataPath string `json:"data_path,omitempty" yaml:"data_path,omitempty"`
HumanIngress *bool `json:"human_ingress" yaml:"human_ingress"`
DebugEvents *bool `json:"debug_events" yaml:"debug_events"`
DirectMessages *bool `json:"direct_messages" yaml:"direct_messages"`
AllowedOrigins []string `json:"allowed_origins,omitempty" yaml:"allowed_origins,omitempty"`
TrustForwardedProto bool `json:"trust_forwarded_proto,omitempty" yaml:"trust_forwarded_proto,omitempty"`
Expand Down
6 changes: 6 additions & 0 deletions internal/app/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func mergeFileConfig(config Config, fileConfig rawConfigFile) Config {
if fileConfig.Server.HumanIngress != nil {
config.AllowHumanIngress = *fileConfig.Server.HumanIngress
}
if fileConfig.Server.DebugEvents != nil {
config.DebugEvents = *fileConfig.Server.DebugEvents
}
if fileConfig.Server.DirectMessages != nil {
config.DisableDirectMessages = !*fileConfig.Server.DirectMessages
}
Expand Down Expand Up @@ -119,6 +122,9 @@ func mergeEnvConfig(config Config) (Config, error) {
if value, ok := envBoolValue("MOLTNET_ALLOW_HUMAN_INGRESS"); ok {
config.AllowHumanIngress = value
}
if value, ok := envBoolValue("MOLTNET_DEBUG_EVENTS"); ok {
config.DebugEvents = value
}
if value, ok := envBoolValue("MOLTNET_ALLOW_DIRECT_MESSAGES"); ok {
config.DisableDirectMessages = !value
}
Expand Down
8 changes: 8 additions & 0 deletions internal/app/config_load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ network:
server:
listen_addr: 127.0.0.1:8787
human_ingress: false
debug_events: true
direct_messages: false
allowed_origins:
- http://localhost:8787
Expand Down Expand Up @@ -92,6 +93,9 @@ pairings:
if config.AllowHumanIngress {
t.Fatalf("expected human ingress disabled, got %#v", config)
}
if !config.DebugEvents {
t.Fatalf("expected debug events enabled, got %#v", config)
}
if !config.DisableDirectMessages {
t.Fatalf("expected direct messages disabled, got %#v", config)
}
Expand Down Expand Up @@ -130,6 +134,7 @@ server:
t.Setenv("MOLTNET_NETWORK_ID", "from_env")
t.Setenv("MOLTNET_NETWORK_NAME", "From Env")
t.Setenv("MOLTNET_ALLOW_HUMAN_INGRESS", "false")
t.Setenv("MOLTNET_DEBUG_EVENTS", "true")
t.Setenv("MOLTNET_ALLOW_DIRECT_MESSAGES", "false")

config, err := LoadConfig("1.2.3")
Expand All @@ -143,6 +148,9 @@ server:
if config.AllowHumanIngress {
t.Fatalf("expected env bool override, got %#v", config)
}
if !config.DebugEvents {
t.Fatalf("expected debug events env override, got %#v", config)
}
if !config.DisableDirectMessages {
t.Fatalf("expected direct messages env override, got %#v", config)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/app/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
func TestConfigFromEnv(t *testing.T) {
t.Setenv("MOLTNET_ALLOW_HUMAN_INGRESS", "false")
t.Setenv("MOLTNET_ALLOW_DIRECT_MESSAGES", "false")
t.Setenv("MOLTNET_DEBUG_EVENTS", "true")
t.Setenv("MOLTNET_LISTEN_ADDR", ":9999")
t.Setenv("MOLTNET_NETWORK_ID", "lab")
t.Setenv("MOLTNET_NETWORK_NAME", "Lab")
Expand All @@ -20,6 +21,9 @@ func TestConfigFromEnv(t *testing.T) {
if config.AllowHumanIngress {
t.Fatalf("expected human ingress disabled, got %#v", config)
}
if !config.DebugEvents {
t.Fatalf("expected debug events enabled, got %#v", config)
}
if !config.DisableDirectMessages {
t.Fatalf("expected direct messages disabled, got %#v", config)
}
Expand Down
Loading
Loading