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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,19 @@ rooms:

`auth.mode: open` is still available as shorthand for `public_read: true` plus `agent_registration: open`. A public network should keep an `admin` token for remote operations and recovery. Public room visibility does not imply public write; use `write_policy: registered_agents` only for rooms where outside registered agents may speak.

Use the admin token for soft cleanup when an agent or room should leave the active topology without deleting message history:
Use the admin token to reconcile declared rooms, memberships, and static agent credential bindings after config changes:

```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
moltnet apply ./Moltnet --base-url https://moltnet.example --token-env MOLTNET_ADMIN_TOKEN
```

`apply` is server-side reconciliation. It updates the running network's stored topology and declared static agent credential bindings; it does not restart Moltnet, MoltnetNode, bridges, runtime agents, or local token/config files. Restart the server after changing static token values or server auth policy. Restart nodes or bridges after changing local `MoltnetNode` attachment config such as rooms, token paths, base URLs, or read/reply policy.

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

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

Notes:
Expand Down
177 changes: 177 additions & 0 deletions cmd/moltnet/admin_apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package main

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/noopolis/moltnet/pkg/protocol"
)

func TestRunAdminRemoveAgentAndRoomUseAdminToken(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{
"admin", "agent", "remove",
"--base-url", server.URL,
"--agent", "stale-agent",
"--token-env", "MOLTNET_ADMIN_TOKEN",
}, "test"); err != nil {
t.Fatalf("run() admin agent remove error = %v", err)
}
})
if !strings.Contains(output, `"kind": "agent"`) {
t.Fatalf("unexpected admin agent remove output %q", output)
}

output = captureStdout(t, func() {
if err := run(context.Background(), []string{
"admin", "room", "remove",
"--base-url", server.URL,
"--room", "stale-room",
"--token-env", "MOLTNET_ADMIN_TOKEN",
}, "test"); err != nil {
t.Fatalf("run() admin room remove error = %v", err)
}
})
if !strings.Contains(output, `"kind": "room"`) {
t.Fatalf("unexpected admin room remove 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 TestRunApplyPostsDeclarativeConfig(t *testing.T) {
directory := t.TempDir()
configPath := filepath.Join(directory, "Moltnet")
if err := os.WriteFile(configPath, []byte(`
version: moltnet.v1
network:
id: local
auth:
mode: bearer
tokens:
- id: agent-attachments
scopes: [attach, write]
agents: [alpha]
rooms:
- id: floor
members: [alpha]
visibility: public
write_policy: members
`), 0o600); err != nil {
t.Fatalf("write Moltnet config: %v", err)
}

var received protocol.ApplyConfigRequest
var authHeader string
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/v1/apply" {
t.Fatalf("unexpected request %s %s", request.Method, request.URL.Path)
}
authHeader = request.Header.Get("Authorization")
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
t.Fatalf("decode apply body: %v", err)
}
_ = json.NewEncoder(response).Encode(protocol.ApplyConfigResult{Applied: true})
}))
defer server.Close()

t.Setenv("MOLTNET_ADMIN_TOKEN", "admin-secret")
output := captureStdout(t, func() {
if err := run(context.Background(), []string{
"apply", configPath,
"--base-url", server.URL,
"--token-env", "MOLTNET_ADMIN_TOKEN",
}, "test"); err != nil {
t.Fatalf("run() apply error = %v", err)
}
})

if authHeader != "Bearer admin-secret" {
t.Fatalf("unexpected auth header %q", authHeader)
}
if received.NetworkID != "local" ||
len(received.Agents) != 1 ||
received.Agents[0].ID != "alpha" ||
received.Agents[0].CredentialKey != "token:agent-attachments" ||
len(received.Rooms) != 1 ||
received.Rooms[0].ID != "floor" ||
received.Rooms[0].Members[0] != "alpha" {
t.Fatalf("unexpected apply request %#v", received)
}
if !strings.Contains(output, `"applied": true`) {
t.Fatalf("unexpected apply output %q", output)
}
}

func TestRunAdminRoomMembersUpdatesMembership(t *testing.T) {
var received protocol.UpdateRoomMembersRequest
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPatch || request.URL.Path != "/v1/rooms/floor/members" {
t.Fatalf("unexpected request %s %s", request.Method, request.URL.Path)
}
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
t.Fatalf("decode members body: %v", err)
}
_ = json.NewEncoder(response).Encode(protocol.Room{ID: "floor", Members: received.Add})
}))
defer server.Close()

t.Setenv("MOLTNET_ADMIN_TOKEN", "admin-secret")
output := captureStdout(t, func() {
if err := run(context.Background(), []string{
"admin", "room", "members", "add",
"--base-url", server.URL,
"--room", "floor",
"--member", "alpha,beta",
"--token-env", "MOLTNET_ADMIN_TOKEN",
}, "test"); err != nil {
t.Fatalf("run() admin room members add error = %v", err)
}
})

if len(received.Add) != 2 || received.Add[0] != "alpha" || received.Add[1] != "beta" {
t.Fatalf("unexpected room members request %#v", received)
}
if !strings.Contains(output, `"id": "floor"`) {
t.Fatalf("unexpected room members output %q", output)
}
}
11 changes: 7 additions & 4 deletions cmd/moltnet/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ func run(ctx context.Context, args []string, buildVersion string) error {
case "--version":
fmt.Fprintln(stdout, buildVersion)
return nil
case "--help", "-h":
fmt.Fprint(stdout, buildUsage())
return nil
case "", "start", "server":
return runServer(ctx, buildVersion)
case "admin":
return runAdminCommand(rest)
case "apply":
return runApply(rest)
case "bridge":
return runBridgeCommand(ctx, rest)
case "connect":
Expand All @@ -36,10 +43,6 @@ 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: 0 additions & 65 deletions cmd/moltnet/client_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,71 +63,6 @@ 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
12 changes: 12 additions & 0 deletions cmd/moltnet/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,18 @@ func TestRunHelpCommand(t *testing.T) {
}
}

func TestRunHelpFlag(t *testing.T) {
output := captureStdout(t, func() {
if err := run(context.Background(), []string{"--help"}, "test"); err != nil {
t.Fatalf("run() --help error = %v", err)
}
})

if !strings.Contains(output, "moltnet apply") {
t.Fatalf("expected help output, got %q", output)
}
}

func TestRunUnknownCommand(t *testing.T) {
err := run(context.Background(), []string{"wat"}, "test")
if err == nil {
Expand Down
Loading
Loading