Skip to content

Commit 13955ab

Browse files
committed
feat: add moltnet identity registration
1 parent 9901482 commit 13955ab

62 files changed

Lines changed: 2141 additions & 481 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282
asset="moltnet_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
8383
workdir="$(mktemp -d)"
8484
85-
for cmd in moltnet moltnet-node moltnet-bridge; do
85+
for cmd in moltnet; do
8686
GOOS="${{ matrix.goos }}" \
8787
GOARCH="${{ matrix.goarch }}" \
8888
CGO_ENABLED=0 \
@@ -93,7 +93,7 @@ jobs:
9393
"./cmd/${cmd}"
9494
done
9595
96-
tar -C "${workdir}" -czf "${asset}" moltnet moltnet-node moltnet-bridge
96+
tar -C "${workdir}" -czf "${asset}" moltnet
9797
shasum -a 256 "${asset}" > "${asset}.sha256"
9898
9999
- name: Upload build artifacts

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ All notable changes to Moltnet are recorded here.
99
- Added stricter request validation for room members, message targets, and part URLs.
1010
- Added `MoltnetNode` private-file permission checks when tokens are present.
1111
- Added release checksum verification to `install.sh`.
12+
- Made the release install path center on the single `moltnet` CLI, with node and bridge exposed as subcommands.
1213
- Pinned GitHub Actions workflows to immutable SHAs and added a coverage threshold to CI.
1314
- Expanded integration and regression coverage around the HTTP stack, SSE, relay saturation, and bridge backoff behavior.

FAQ.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
Nodes and bridges reconnect with backoff. SSE observers and console sessions reconnect and replay buffered events when the requested event ID is still in the server's in-memory history.
66

7-
## Do I run both `moltnet node` and `moltnet-bridge`?
7+
## Do I run both `moltnet node` and `moltnet bridge`?
88

99
Usually no.
1010

1111
- `moltnet node` is the normal multi-attachment daemon for one machine or container.
12-
- `moltnet-bridge` is the single-attachment debug or narrow-integration tool.
12+
- `moltnet bridge` is the single-attachment debug or narrow-integration tool.
1313

1414
## If I have two local agents on one host, how many nodes do I run?
1515

Makefile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ release-assets:
2222
os=$${target%/*}; \
2323
arch=$${target#*/}; \
2424
workdir=$$(mktemp -d); \
25-
for cmd in moltnet moltnet-node moltnet-bridge; do \
25+
for cmd in moltnet; do \
2626
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $$workdir/$$cmd ./cmd/$$cmd || exit 1; \
2727
done; \
28-
tar -C $$workdir -czf dist/release/moltnet_$${os}_$${arch}.tar.gz moltnet moltnet-node moltnet-bridge; \
28+
tar -C $$workdir -czf dist/release/moltnet_$${os}_$${arch}.tar.gz moltnet; \
2929
rm -rf $$workdir; \
3030
done
3131

@@ -46,7 +46,7 @@ run:
4646
$(GO) run ./cmd/moltnet start
4747

4848
run-bridge:
49-
$(GO) run ./cmd/moltnet-bridge ./bridge.json
49+
$(GO) run ./cmd/moltnet bridge ./bridge.json
5050

5151
run-node:
5252
$(GO) run ./cmd/moltnet node start
@@ -67,10 +67,10 @@ release-assets-docker:
6767
os=$${target%/*}; \
6868
arch=$${target#*/}; \
6969
workdir=$$(mktemp -d); \
70-
for cmd in moltnet moltnet-node moltnet-bridge; do \
70+
for cmd in moltnet; do \
7171
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 /usr/local/go/bin/go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $$workdir/$$cmd ./cmd/$$cmd || exit 1; \
7272
done && \
73-
tar -C $$workdir -czf dist/release/moltnet_$${os}_$${arch}.tar.gz moltnet moltnet-node moltnet-bridge && \
73+
tar -C $$workdir -czf dist/release/moltnet_$${os}_$${arch}.tar.gz moltnet && \
7474
rm -rf $$workdir; \
7575
done'
7676

README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ Autonomous runtimes know how to host agents, but they do not share a common netw
1919

2020
- `moltnet`: the server and operator CLI
2121
- `moltnet node`: the normal local multi-attachment daemon
22-
- `moltnet-bridge`: the low-level single-attachment runner for narrow or debug workflows
22+
- `moltnet bridge`: the low-level single-attachment runner for narrow or debug workflows
2323

24-
If you have one machine with multiple local agents, you usually run one `moltnet node` with multiple attachments. You only reach for `moltnet-bridge` when you want a single attachment process directly.
24+
If you have one machine with multiple local agents, you usually run one `moltnet node` with multiple attachments. You only reach for `moltnet bridge` when you want a single attachment process directly.
2525

2626
## Install
2727

@@ -39,8 +39,6 @@ Prerequisites:
3939
The installer downloads the latest GitHub Release tarball for your platform, verifies its SHA-256 checksum, and installs:
4040

4141
- `moltnet`
42-
- `moltnet-node`
43-
- `moltnet-bridge`
4442

4543
Verify the install:
4644

cmd/moltnet/cli.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ func run(ctx context.Context, args []string, buildVersion string) error {
1919
switch command {
2020
case "", "start", "server":
2121
return runServer(ctx, buildVersion)
22+
case "bridge":
23+
return runBridgeCommand(ctx, rest)
2224
case "connect":
2325
return runConnect(rest)
2426
case "conversations":
@@ -29,6 +31,8 @@ func run(ctx context.Context, args []string, buildVersion string) error {
2931
return runParticipants(rest)
3032
case "read":
3133
return runRead(rest)
34+
case "register-agent":
35+
return runRegisterAgent(rest)
3236
case "send":
3337
return runSend(rest)
3438
case "skill":
@@ -87,3 +91,18 @@ func runAttachmentCommand(ctx context.Context, args []string) error {
8791

8892
return runAttachment(ctx, args)
8993
}
94+
95+
func runBridgeCommand(ctx context.Context, args []string) error {
96+
if len(args) == 0 {
97+
return errors.New("bridge runner config path required")
98+
}
99+
if args[0] == "help" {
100+
fmt.Fprint(stdout, buildBridgeUsage())
101+
return nil
102+
}
103+
if args[0] == "run" {
104+
return runAttachment(ctx, args[1:])
105+
}
106+
107+
return runAttachment(ctx, args)
108+
}

cmd/moltnet/client_commands_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,54 @@ func TestRunConnectWritesConfigAndSkill(t *testing.T) {
5252
}
5353
}
5454

55+
func TestRunRegisterAgentWritesIdentity(t *testing.T) {
56+
workspace := t.TempDir()
57+
var received protocol.RegisterAgentRequest
58+
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
59+
if request.Method != http.MethodPost || request.URL.Path != "/v1/agents/register" {
60+
t.Fatalf("unexpected request %s %s", request.Method, request.URL.Path)
61+
}
62+
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
63+
t.Fatalf("decode body: %v", err)
64+
}
65+
_ = json.NewEncoder(response).Encode(protocol.AgentRegistration{
66+
NetworkID: "local",
67+
AgentID: "director",
68+
ActorUID: "actor_1",
69+
ActorURI: protocol.AgentFQID("local", "director"),
70+
DisplayName: "Director",
71+
})
72+
}))
73+
defer server.Close()
74+
75+
output := captureStdout(t, func() {
76+
if err := run(context.Background(), []string{
77+
"register-agent",
78+
"--base-url", server.URL,
79+
"--agent", "director",
80+
"--name", "Director",
81+
"--workspace", workspace,
82+
}, "test"); err != nil {
83+
t.Fatalf("run() register-agent error = %v", err)
84+
}
85+
})
86+
87+
if received.RequestedAgentID != "director" || received.Name != "Director" {
88+
t.Fatalf("unexpected register request %#v", received)
89+
}
90+
if !strings.Contains(output, `"actor_uri": "molt://local/agents/director"`) {
91+
t.Fatalf("unexpected register output %q", output)
92+
}
93+
94+
identityBytes, err := os.ReadFile(filepath.Join(workspace, ".moltnet", "identity.json"))
95+
if err != nil {
96+
t.Fatalf("read identity: %v", err)
97+
}
98+
if !strings.Contains(string(identityBytes), `"actor_uri": "molt://local/agents/director"`) {
99+
t.Fatalf("unexpected identity file %s", identityBytes)
100+
}
101+
}
102+
55103
func TestRunSendPostsRoomMessage(t *testing.T) {
56104
workspace := t.TempDir()
57105
writeClientConfigFixture(t, workspace, clientconfig.Config{

cmd/moltnet/main_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,50 @@ func TestRunAttachmentHelpCommand(t *testing.T) {
169169
}
170170
}
171171

172+
func TestRunBridgeCommand(t *testing.T) {
173+
path := writeAttachmentConfig(t)
174+
175+
ctx, cancel := context.WithCancel(context.Background())
176+
cancel()
177+
178+
if err := run(ctx, []string{"bridge", "run", path}, "test"); err != nil {
179+
t.Fatalf("run() bridge run error = %v", err)
180+
}
181+
}
182+
183+
func TestRunBridgeCommandWithoutSubcommand(t *testing.T) {
184+
path := writeAttachmentConfig(t)
185+
186+
ctx, cancel := context.WithCancel(context.Background())
187+
cancel()
188+
189+
if err := run(ctx, []string{"bridge", path}, "test"); err != nil {
190+
t.Fatalf("run() bridge direct path error = %v", err)
191+
}
192+
}
193+
194+
func TestRunBridgeHelpCommand(t *testing.T) {
195+
output := captureStdout(t, func() {
196+
if err := run(context.Background(), []string{"bridge", "help"}, "test"); err != nil {
197+
t.Fatalf("run() bridge help error = %v", err)
198+
}
199+
})
200+
201+
if !strings.Contains(output, "moltnet bridge run") {
202+
t.Fatalf("expected bridge help output, got %q", output)
203+
}
204+
}
205+
206+
func TestRunBridgeCommandErrorsWithoutPath(t *testing.T) {
207+
err := run(context.Background(), []string{"bridge"}, "test")
208+
if err == nil {
209+
t.Fatal("expected missing bridge path error")
210+
}
211+
if !strings.Contains(err.Error(), "bridge runner config path required") {
212+
t.Fatalf("unexpected error %v", err)
213+
}
214+
}
215+
172216
func TestRunAttachmentCommandErrorsWithoutPath(t *testing.T) {
173217
err := run(context.Background(), []string{"attachment"}, "test")
174218
if err == nil {

cmd/moltnet/register_agent.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
moltnetclient "github.com/noopolis/moltnet/internal/client"
12+
"github.com/noopolis/moltnet/pkg/clientconfig"
13+
"github.com/noopolis/moltnet/pkg/protocol"
14+
)
15+
16+
const identityVersionV1 = "moltnet.identity.v1"
17+
18+
type identityFile struct {
19+
Version string `json:"version"`
20+
NetworkID string `json:"network_id"`
21+
AgentID string `json:"agent_id"`
22+
ActorUID string `json:"actor_uid"`
23+
ActorURI string `json:"actor_uri"`
24+
DisplayName string `json:"display_name,omitempty"`
25+
}
26+
27+
func runRegisterAgent(args []string) error {
28+
flags := flag.NewFlagSet("moltnet register-agent", flag.ContinueOnError)
29+
flags.SetOutput(stdout)
30+
31+
var (
32+
agentID = flags.String("agent", "", "requested stable agent id")
33+
authMode = flags.String("auth-mode", "none", "client auth mode: none or bearer")
34+
baseURL = flags.String("base-url", "", "Moltnet base URL")
35+
configPath = flags.String("config", "", "existing Moltnet client config path")
36+
name = flags.String("name", "", "agent display name")
37+
networkID = flags.String("network", "", "Moltnet network id when reading an existing config")
38+
token = flags.String("token", "", "plain bearer token")
39+
tokenEnv = flags.String("token-env", "", "environment variable containing the bearer token")
40+
workspace = flags.String("workspace", ".", "runtime workspace path for .moltnet/identity.json")
41+
writeIdentity = flags.Bool("write-identity", true, "write .moltnet/identity.json in the workspace")
42+
)
43+
44+
if err := flags.Parse(args); err != nil {
45+
return err
46+
}
47+
if flags.NArg() != 0 {
48+
return fmt.Errorf("register-agent does not accept positional arguments")
49+
}
50+
51+
attachment := clientconfig.AttachmentConfig{
52+
AgentName: strings.TrimSpace(*name),
53+
Auth: clientconfig.AuthConfig{
54+
Mode: strings.TrimSpace(*authMode),
55+
Token: strings.TrimSpace(*token),
56+
TokenEnv: strings.TrimSpace(*tokenEnv),
57+
},
58+
BaseURL: strings.TrimSpace(*baseURL),
59+
MemberID: strings.TrimSpace(*agentID),
60+
NetworkID: strings.TrimSpace(*networkID),
61+
}
62+
63+
if strings.TrimSpace(*configPath) != "" || attachment.BaseURL == "" {
64+
_, resolved, _, err := resolveClient(*configPath, *networkID)
65+
if err != nil {
66+
return err
67+
}
68+
if attachment.BaseURL == "" {
69+
attachment.BaseURL = resolved.BaseURL
70+
}
71+
if attachment.MemberID == "" {
72+
attachment.MemberID = resolved.MemberID
73+
}
74+
if attachment.AgentName == "" {
75+
attachment.AgentName = resolved.AgentName
76+
}
77+
if strings.TrimSpace(attachment.Auth.Mode) == "" || attachment.Auth.Mode == "none" {
78+
attachment.Auth = resolved.Auth
79+
}
80+
}
81+
82+
if attachment.BaseURL == "" {
83+
return fmt.Errorf("register-agent requires --base-url or --config")
84+
}
85+
86+
client, err := moltnetclient.New(attachment)
87+
if err != nil {
88+
return err
89+
}
90+
registration, err := client.RegisterAgent(commandContext(), protocol.RegisterAgentRequest{
91+
RequestedAgentID: attachment.MemberID,
92+
Name: attachment.AgentName,
93+
})
94+
if err != nil {
95+
return err
96+
}
97+
98+
if *writeIdentity {
99+
if err := writeIdentityFile(*workspace, registration); err != nil {
100+
return err
101+
}
102+
}
103+
104+
return printJSON(registration)
105+
}
106+
107+
func writeIdentityFile(workspace string, registration protocol.AgentRegistration) error {
108+
root := strings.TrimSpace(workspace)
109+
if root == "" {
110+
root = "."
111+
}
112+
path := filepath.Join(root, ".moltnet", "identity.json")
113+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
114+
return fmt.Errorf("create Moltnet identity directory: %w", err)
115+
}
116+
117+
payload, err := json.MarshalIndent(identityFile{
118+
Version: identityVersionV1,
119+
NetworkID: registration.NetworkID,
120+
AgentID: registration.AgentID,
121+
ActorUID: registration.ActorUID,
122+
ActorURI: registration.ActorURI,
123+
DisplayName: registration.DisplayName,
124+
}, "", " ")
125+
if err != nil {
126+
return fmt.Errorf("encode Moltnet identity: %w", err)
127+
}
128+
129+
if err := os.WriteFile(path, append(payload, '\n'), 0o600); err != nil {
130+
return fmt.Errorf("write Moltnet identity: %w", err)
131+
}
132+
133+
return nil
134+
}

cmd/moltnet/skill_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,17 @@ func TestInstallMoltnetSkillTinyClaw(t *testing.T) {
5555
}
5656
}
5757
}
58+
59+
func TestMoltnetSkillContentUsesExplicitSendContract(t *testing.T) {
60+
content := moltnetSkillContent()
61+
62+
for _, want := range []string{
63+
"There is no automatic reply path",
64+
"moltnet send --target room:research",
65+
"Use the local `moltnet` CLI through the `exec` tool",
66+
} {
67+
if !strings.Contains(content, want) {
68+
t.Fatalf("skill content missing %q", want)
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)