spawnfile 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/lifecycleCommands.d.ts +3 -0
  3. package/dist/cli/lifecycleCommands.js +80 -0
  4. package/dist/cli/runCli.d.ts +2 -1
  5. package/dist/cli/runCli.js +5 -47
  6. package/dist/compiler/buildCompilePlan.js +12 -202
  7. package/dist/compiler/buildCompilePlanRuntime.d.ts +6 -1
  8. package/dist/compiler/buildCompilePlanRuntime.js +9 -0
  9. package/dist/compiler/buildCompilePlanTeams.js +16 -10
  10. package/dist/compiler/buildCompilePlanTraversal.d.ts +16 -0
  11. package/dist/compiler/buildCompilePlanTraversal.js +214 -0
  12. package/dist/compiler/buildCompilePlanTraversalHelpers.d.ts +18 -0
  13. package/dist/compiler/buildCompilePlanTraversalHelpers.js +22 -0
  14. package/dist/compiler/compilePlanHelpers.d.ts +3 -1
  15. package/dist/compiler/compilePlanHelpers.js +37 -1
  16. package/dist/compiler/compileProject.js +14 -0
  17. package/dist/compiler/containerArtifacts.js +18 -3
  18. package/dist/compiler/containerArtifactsPlans.js +32 -0
  19. package/dist/compiler/containerArtifactsRender.js +86 -3
  20. package/dist/compiler/containerArtifactsTypes.d.ts +7 -3
  21. package/dist/compiler/containerEntrypointRender.d.ts +1 -1
  22. package/dist/compiler/containerEntrypointRender.js +37 -27
  23. package/dist/compiler/containerTargetResources.d.ts +4 -0
  24. package/dist/compiler/containerTargetResources.js +54 -0
  25. package/dist/compiler/containerWorkspaceResourceRender.d.ts +3 -0
  26. package/dist/compiler/containerWorkspaceResourceRender.js +128 -0
  27. package/dist/compiler/executionDefaults.js +0 -3
  28. package/dist/compiler/helpers.d.ts +1 -1
  29. package/dist/compiler/index.d.ts +1 -0
  30. package/dist/compiler/index.js +1 -0
  31. package/dist/compiler/moltnetArtifacts.d.ts +11 -5
  32. package/dist/compiler/moltnetArtifacts.js +133 -117
  33. package/dist/compiler/moltnetClientConfig.js +8 -2
  34. package/dist/compiler/moltnetConfigLowering.d.ts +36 -0
  35. package/dist/compiler/moltnetConfigLowering.js +125 -0
  36. package/dist/compiler/moltnetRuntimeConfig.d.ts +2 -0
  37. package/dist/compiler/moltnetRuntimeConfig.js +69 -0
  38. package/dist/compiler/runProject.d.ts +2 -0
  39. package/dist/compiler/runProject.js +20 -6
  40. package/dist/compiler/surfaces.d.ts +3 -13
  41. package/dist/compiler/surfaces.js +1 -6
  42. package/dist/compiler/syncProjectAuth.js +67 -19
  43. package/dist/compiler/types.d.ts +16 -1
  44. package/dist/compiler/upProject.d.ts +19 -0
  45. package/dist/compiler/upProject.js +37 -0
  46. package/dist/compiler/view/buildOrganizationView.js +22 -2
  47. package/dist/compiler/view/renderNetworks.js +14 -3
  48. package/dist/compiler/view/renderTree.js +8 -2
  49. package/dist/compiler/view/types.d.ts +18 -3
  50. package/dist/compiler/workspaceResources.d.ts +34 -0
  51. package/dist/compiler/workspaceResources.js +120 -0
  52. package/dist/manifest/executionSchemas.d.ts +106 -0
  53. package/dist/manifest/executionSchemas.js +140 -0
  54. package/dist/manifest/loadManifest.js +15 -27
  55. package/dist/manifest/renderSpawnfile.js +44 -52
  56. package/dist/manifest/renderSpawnfileNetworks.d.ts +2 -0
  57. package/dist/manifest/renderSpawnfileNetworks.js +63 -0
  58. package/dist/manifest/renderSpawnfileWorkspace.d.ts +2 -0
  59. package/dist/manifest/renderSpawnfileWorkspace.js +47 -0
  60. package/dist/manifest/scaffold.js +12 -6
  61. package/dist/manifest/scheduleSchemas.d.ts +15 -0
  62. package/dist/manifest/scheduleSchemas.js +26 -0
  63. package/dist/manifest/schemas.d.ts +626 -368
  64. package/dist/manifest/schemas.js +51 -191
  65. package/dist/manifest/teamNetworkSchemas.d.ts +228 -0
  66. package/dist/manifest/teamNetworkSchemas.js +295 -0
  67. package/dist/manifest/workspaceSchemas.d.ts +96 -0
  68. package/dist/manifest/workspaceSchemas.js +166 -0
  69. package/dist/report/types.d.ts +10 -0
  70. package/dist/runtime/common.d.ts +2 -1
  71. package/dist/runtime/common.js +3 -3
  72. package/dist/runtime/picoclaw/adapter.js +7 -0
  73. package/dist/runtime/picoclaw/runAuth.js +5 -41
  74. package/dist/runtime/tinyclaw/adapter.js +9 -2
  75. package/dist/runtime/tinyclaw/schedules.d.ts +9 -0
  76. package/dist/runtime/tinyclaw/schedules.js +62 -0
  77. package/dist/runtime/types.d.ts +1 -0
  78. package/package.json +2 -1
@@ -1,77 +1,13 @@
1
- import { getRuntimeAdapter } from "../runtime/index.js";
2
1
  import { SpawnfileError } from "../shared/index.js";
2
+ import { createMoltnetNativeServerConfig, createMoltnetNodeConfigPath, createMoltnetServerConfigPath, resolveMoltnetBaseUrl, resolveMoltnetClientAuth } from "./moltnetConfigLowering.js";
3
3
  import { listConcreteMoltnetRoomMemberIds } from "./moltnetRoomMemberships.js";
4
+ import { resolveRuntimeConfig } from "./moltnetRuntimeConfig.js";
4
5
  const DEFAULT_MOLTNET_PORT = 8787;
5
- const DEFAULT_TINYCLAW_PORT = 3777;
6
6
  const ROOTFS_PREFIX = "container/rootfs";
7
- const INSTANCE_ROOT_PLACEHOLDER = "<instance-root>";
8
- const CONFIG_FILE_PLACEHOLDER = "<config-file>";
9
7
  const createServerKey = (networkId) => networkId;
10
- const createBridgeConfigPath = (teamSlug, networkId, agentId) => `${ROOTFS_PREFIX}/var/lib/spawnfile/moltnet/bridges/${teamSlug}-${networkId}-${agentId}.json`;
11
- const resolveSequentialRuntimePort = (plan, runtimeName, slug) => {
12
- const adapter = getRuntimeAdapter(runtimeName);
13
- const basePort = adapter.container.port;
14
- if (basePort === undefined) {
15
- return undefined;
16
- }
17
- const runtimeAgents = plan.nodes.filter((node) => node.kind === "agent" && node.runtimeName === runtimeName);
18
- const index = runtimeAgents.findIndex((node) => node.slug === slug);
19
- if (index < 0) {
20
- return undefined;
21
- }
22
- return basePort + (index * (adapter.container.portStride ?? 1));
23
- };
24
- const createTinyClawChannel = (networkId, agentId) => `moltnet:${networkId}:${agentId}`;
25
- const replaceContainerPathTemplate = (template, instanceRoot, configFileName) => template
26
- .replaceAll(INSTANCE_ROOT_PLACEHOLDER, instanceRoot)
27
- .replaceAll(CONFIG_FILE_PLACEHOLDER, configFileName);
28
- const resolveRuntimeInstancePaths = (runtimeName, slug) => {
29
- const adapter = getRuntimeAdapter(runtimeName);
30
- const instanceRoot = `/var/lib/spawnfile/instances/${runtimeName}/agent-${slug}`;
31
- return {
32
- configPath: replaceContainerPathTemplate(adapter.container.instancePaths.configPathTemplate, instanceRoot, adapter.container.configFileName),
33
- homePath: adapter.container.instancePaths.homePathTemplate
34
- ? replaceContainerPathTemplate(adapter.container.instancePaths.homePathTemplate, instanceRoot, adapter.container.configFileName)
35
- : undefined
36
- };
37
- };
38
- const resolveRuntimeConfig = (plan, agentNode, nodeSlug, networkId, agentId) => {
39
- switch (agentNode.runtime.name) {
40
- case "openclaw": {
41
- const port = resolveSequentialRuntimePort(plan, "openclaw", nodeSlug);
42
- if (!port) {
43
- throw new SpawnfileError("compile_error", `Unable to resolve OpenClaw gateway port for Moltnet agent ${agentNode.name}`);
44
- }
45
- const instancePaths = resolveRuntimeInstancePaths("openclaw", nodeSlug);
46
- return {
47
- gateway_url: `ws://127.0.0.1:${port}`,
48
- ...(instancePaths.homePath ? { home_path: instancePaths.homePath } : {}),
49
- kind: "openclaw",
50
- };
51
- }
52
- case "picoclaw": {
53
- const instancePaths = resolveRuntimeInstancePaths("picoclaw", nodeSlug);
54
- return {
55
- command: "/usr/local/bin/picoclaw",
56
- config_path: instancePaths.configPath,
57
- ...(instancePaths.homePath ? { home_path: instancePaths.homePath } : {}),
58
- kind: "picoclaw",
59
- };
60
- }
61
- case "tinyclaw": {
62
- const channel = createTinyClawChannel(networkId, agentId);
63
- return {
64
- ack_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/responses`,
65
- channel,
66
- inbound_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/message`,
67
- kind: "tinyclaw",
68
- outbound_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/responses/pending?channel=${encodeURIComponent(channel)}`
69
- };
70
- }
71
- default:
72
- throw new SpawnfileError("compile_error", `Moltnet does not know how to attach runtime ${agentNode.runtime.name} directly`);
73
- }
74
- };
8
+ const toContainerRootfsPath = (rootfsPath) => `/${rootfsPath.replace(`${ROOTFS_PREFIX}/`, "")}`;
9
+ const isNetworkHttpEnabled = (network) => network.server?.mode === "managed" && network.server.human_ingress === true;
10
+ const resolveNetworkPort = (network, fallbackPort) => network.server?.mode === "managed" ? network.server.listen.port : fallbackPort;
75
11
  export const generateMoltnetArtifacts = async (plan) => {
76
12
  const teamNodes = plan.nodes
77
13
  .filter((node) => node.kind === "team")
@@ -83,9 +19,16 @@ export const generateMoltnetArtifacts = async (plan) => {
83
19
  let nextPort = DEFAULT_MOLTNET_PORT;
84
20
  for (const teamNode of teamNodes) {
85
21
  for (const network of teamNode.value.networks ?? []) {
22
+ const server = network.server;
23
+ if (!server) {
24
+ throw new SpawnfileError("validation_error", `Moltnet network ${network.id} must declare server`);
25
+ }
86
26
  const serverKey = createServerKey(network.id);
87
27
  const existingPlan = serverPlans.get(serverKey);
88
28
  if (existingPlan) {
29
+ if (existingPlan.baseUrl !== resolveMoltnetBaseUrl(server)) {
30
+ throw new SpawnfileError("validation_error", `Duplicate Moltnet network ${network.id} declares conflicting server URL`);
31
+ }
89
32
  for (const room of network.rooms) {
90
33
  const concreteMembers = listConcreteMoltnetRoomMemberIds(plan, teamNode.value, network.id, room);
91
34
  const existingRoom = existingPlan.rooms.find((entry) => entry.id === room.id);
@@ -97,31 +40,64 @@ export const generateMoltnetArtifacts = async (plan) => {
97
40
  else {
98
41
  existingPlan.rooms.push({
99
42
  id: room.id,
100
- members: concreteMembers
43
+ members: concreteMembers,
44
+ ...(room.name ? { name: room.name } : {})
101
45
  });
102
46
  }
103
47
  }
104
48
  existingPlan.rooms.sort((left, right) => left.id.localeCompare(right.id));
105
49
  }
106
50
  else {
51
+ const port = server.mode === "managed" ? resolveNetworkPort(network, nextPort) : undefined;
52
+ const serverId = `${teamNode.slug}-${network.id}`;
107
53
  serverPlans.set(serverKey, {
108
- id: `${teamNode.slug}-${network.id}`,
54
+ baseUrl: resolveMoltnetBaseUrl(server),
55
+ ...(server.mode === "managed"
56
+ ? { configPath: toContainerRootfsPath(createMoltnetServerConfigPath(serverId)) }
57
+ : {}),
58
+ id: serverId,
59
+ mode: server.mode,
109
60
  name: network.name,
110
61
  networkId: network.id,
111
- port: nextPort,
62
+ ...(port ? { port } : {}),
112
63
  rooms: network.rooms.map((room) => ({
113
64
  id: room.id,
114
- members: listConcreteMoltnetRoomMemberIds(plan, teamNode.value, network.id, room)
65
+ members: listConcreteMoltnetRoomMemberIds(plan, teamNode.value, network.id, room),
66
+ ...(room.name ? { name: room.name } : {})
115
67
  })),
68
+ server,
69
+ secretPatches: [],
116
70
  teamSource: teamNode.value.source
117
71
  });
118
- nextPort += 1;
72
+ if (port) {
73
+ nextPort = Math.max(nextPort, port + 1);
74
+ }
119
75
  }
120
76
  }
121
77
  }
122
- const bridgePlans = [];
123
- const bridgePlanKeys = new Set();
78
+ const nodePlans = [];
79
+ const nodePlanKeys = new Set();
124
80
  const configFiles = [];
81
+ for (const teamNode of teamNodes) {
82
+ for (const network of teamNode.value.networks ?? []) {
83
+ const serverPlan = serverPlans.get(createServerKey(network.id));
84
+ if (!serverPlan || !network.server || network.server.mode !== "managed" || !serverPlan.configPath) {
85
+ continue;
86
+ }
87
+ const native = createMoltnetNativeServerConfig({
88
+ networkId: network.id,
89
+ networkName: network.name,
90
+ rooms: serverPlan.rooms,
91
+ server: network.server
92
+ });
93
+ serverPlan.secretPatches = native.secretPatches;
94
+ configFiles.push({
95
+ content: `${JSON.stringify(native.config, null, 2)}\n`,
96
+ mode: 0o600,
97
+ path: createMoltnetServerConfigPath(serverPlan.id)
98
+ });
99
+ }
100
+ }
125
101
  for (const node of plan.nodes) {
126
102
  if (node.kind !== "agent") {
127
103
  continue;
@@ -142,67 +118,107 @@ export const generateMoltnetArtifacts = async (plan) => {
142
118
  if (!serverPlan) {
143
119
  throw new SpawnfileError("validation_error", `Unable to find Moltnet network ${attachment.network} for ${agentNode.name}`);
144
120
  }
145
- const configPath = createBridgeConfigPath(teamNode.slug, attachment.network, attachment.memberId);
146
- const bridgePlanKey = `${attachment.network}::${attachment.memberId}`;
147
- if (bridgePlanKeys.has(bridgePlanKey)) {
148
- throw new SpawnfileError("validation_error", `Duplicate Moltnet bridge attachment for ${attachment.network}/${attachment.memberId}`);
121
+ const network = teamNode.value.networks?.find((entry) => entry.id === attachment.network);
122
+ if (!network) {
123
+ throw new SpawnfileError("validation_error", `Unable to find Moltnet network ${attachment.network} for ${agentNode.name}`);
124
+ }
125
+ if (network.server?.mode === "managed" && network.server.direct_messages === false && attachment.dms) {
126
+ throw new SpawnfileError("validation_error", `Moltnet network ${attachment.network} disables direct messages but ${agentNode.name} declares dms`);
149
127
  }
150
- bridgePlanKeys.add(bridgePlanKey);
128
+ if (!network.server) {
129
+ throw new SpawnfileError("validation_error", `Moltnet network ${attachment.network} must declare server`);
130
+ }
131
+ const configPath = createMoltnetNodeConfigPath(teamNode.slug, attachment.network, attachment.memberId);
132
+ const nodePlanKey = `${attachment.network}::${attachment.memberId}`;
133
+ if (nodePlanKeys.has(nodePlanKey)) {
134
+ throw new SpawnfileError("validation_error", `Duplicate Moltnet node attachment for ${attachment.network}/${attachment.memberId}`);
135
+ }
136
+ nodePlanKeys.add(nodePlanKey);
137
+ const clientAuth = resolveMoltnetClientAuth(network.server, attachment.network, attachment.memberId);
138
+ const usesPerAttachmentOpenToken = clientAuth.mode === "open" &&
139
+ clientAuth.staticToken !== true &&
140
+ Boolean(clientAuth.tokenEnv || clientAuth.tokenPath);
151
141
  configFiles.push({
152
142
  content: `${JSON.stringify({
153
- version: "moltnet.bridge.v1",
154
- agent: {
155
- id: attachment.memberId,
156
- name: agentNode.name
157
- },
143
+ version: "moltnet.node.v1",
158
144
  moltnet: {
159
- base_url: `http://127.0.0.1:${serverPlan.port}`,
160
- network_id: attachment.network
161
- },
162
- runtime: resolveRuntimeConfig(plan, agentNode, node.slug, attachment.network, attachment.memberId),
163
- ...(attachment.rooms
164
- ? {
165
- rooms: Object.entries(attachment.rooms)
166
- .sort(([left], [right]) => left.localeCompare(right))
167
- .map(([roomId, policy]) => ({
168
- id: roomId,
169
- ...(policy.read ? { read: policy.read } : {}),
170
- ...(policy.reply ? { reply: policy.reply } : {})
171
- }))
172
- }
173
- : {}),
174
- ...(attachment.dms
175
- ? {
176
- dms: {
177
- enabled: attachment.dms.enabled,
178
- ...(attachment.dms.read ? { read: attachment.dms.read } : {}),
179
- ...(attachment.dms.reply ? { reply: attachment.dms.reply } : {})
145
+ base_url: serverPlan.baseUrl,
146
+ network_id: attachment.network,
147
+ auth_mode: clientAuth.mode,
148
+ ...(clientAuth.staticToken
149
+ ? { static_token: true }
150
+ : {}),
151
+ ...(!usesPerAttachmentOpenToken && clientAuth.tokenEnv
152
+ ? {
153
+ token_env: clientAuth.tokenEnv
180
154
  }
155
+ : {}),
156
+ ...(!usesPerAttachmentOpenToken && clientAuth.tokenPath
157
+ ? {
158
+ token_path: clientAuth.tokenPath
159
+ }
160
+ : {})
161
+ },
162
+ attachments: [
163
+ {
164
+ agent: {
165
+ id: attachment.memberId,
166
+ name: agentNode.name
167
+ },
168
+ ...(usesPerAttachmentOpenToken
169
+ ? {
170
+ moltnet: {
171
+ ...(clientAuth.tokenEnv ? { token_env: clientAuth.tokenEnv } : {}),
172
+ ...(clientAuth.tokenPath ? { token_path: clientAuth.tokenPath } : {})
173
+ }
174
+ }
175
+ : {}),
176
+ runtime: resolveRuntimeConfig(plan, agentNode, node.slug, attachment.network, attachment.memberId),
177
+ ...(attachment.rooms
178
+ ? {
179
+ rooms: Object.entries(attachment.rooms)
180
+ .sort(([left], [right]) => left.localeCompare(right))
181
+ .map(([roomId, policy]) => ({
182
+ id: roomId,
183
+ ...(policy.read ? { read: policy.read } : {}),
184
+ ...(policy.reply ? { reply: policy.reply } : {})
185
+ }))
186
+ }
187
+ : {}),
188
+ ...(attachment.dms
189
+ ? {
190
+ dms: {
191
+ enabled: attachment.dms.enabled,
192
+ ...(attachment.dms.read ? { read: attachment.dms.read } : {}),
193
+ ...(attachment.dms.reply ? { reply: attachment.dms.reply } : {})
194
+ }
195
+ }
196
+ : {})
181
197
  }
182
- : {})
198
+ ]
183
199
  }, null, 2)}\n`,
184
200
  mode: 0o600,
185
201
  path: configPath
186
202
  });
187
- bridgePlans.push({
188
- agentId: attachment.memberId,
189
- configPath: `/${configPath.replace(`${ROOTFS_PREFIX}/`, "")}`,
190
- networkId: attachment.network,
191
- runtime: agentNode.runtime.name
203
+ nodePlans.push({
204
+ configPath: toContainerRootfsPath(configPath),
205
+ networkId: attachment.network
192
206
  });
193
207
  }
194
208
  }
209
+ const managedServerPlans = [...serverPlans.values()].filter((serverPlan) => serverPlan.mode === "managed");
195
210
  return {
196
- bridgePlans: bridgePlans.sort((left, right) => left.configPath.localeCompare(right.configPath)),
197
211
  files: configFiles,
198
- ports: [...new Set([...serverPlans.values()].map((plan) => plan.port))].sort((left, right) => left - right),
212
+ nodePlans: nodePlans.sort((left, right) => left.configPath.localeCompare(right.configPath)),
213
+ ports: [...new Set(managedServerPlans.map((serverPlan) => serverPlan.port).filter((port) => port !== undefined))].sort((left, right) => left - right),
199
214
  publishedPorts: [
200
215
  ...new Set(teamNodes
201
- .flatMap((teamNode) => (teamNode.value.networks ?? []).map((network) => network.expose
216
+ .flatMap((teamNode) => (teamNode.value.networks ?? []).map((network) => isNetworkHttpEnabled(network)
202
217
  ? serverPlans.get(createServerKey(network.id))?.port
203
218
  : undefined))
204
219
  .filter((port) => port !== undefined))
205
220
  ].sort((left, right) => left - right),
206
- serverPlans: [...serverPlans.values()].sort((left, right) => left.port - right.port)
221
+ serverPlans: [...serverPlans.values()].sort((left, right) => (left.port ?? Number.MAX_SAFE_INTEGER) - (right.port ?? Number.MAX_SAFE_INTEGER)
222
+ || left.networkId.localeCompare(right.networkId))
207
223
  };
208
224
  };
@@ -1,4 +1,5 @@
1
1
  import { SpawnfileError } from "../shared/index.js";
2
+ import { resolveMoltnetClientAuth } from "./moltnetConfigLowering.js";
2
3
  const GENERATED_SKILL_NAME = "moltnet";
3
4
  const GENERATED_CONFIG_PATH = ".moltnet/config.json";
4
5
  const createConfigContent = (node, attachments) => `${JSON.stringify({
@@ -23,10 +24,15 @@ const createAttachmentConfig = (node, artifacts, attachment) => {
23
24
  throw new SpawnfileError("compile_error", `Moltnet client config requires a resolved member id for ${node.name}`);
24
25
  }
25
26
  const serverPlan = findServerPlan(artifacts, attachment);
27
+ const auth = resolveMoltnetClientAuth(serverPlan.server, attachment.network, attachment.memberId);
26
28
  return {
27
29
  agent_name: node.name,
28
- auth: { mode: "none" },
29
- base_url: `http://127.0.0.1:${serverPlan.port}`,
30
+ auth: {
31
+ mode: auth.mode,
32
+ ...(auth.tokenEnv ? { token_env: auth.tokenEnv } : {}),
33
+ ...(auth.tokenPath ? { token_path: auth.tokenPath } : {})
34
+ },
35
+ base_url: serverPlan.baseUrl,
30
36
  ...(attachment.dms
31
37
  ? {
32
38
  dms: {
@@ -0,0 +1,36 @@
1
+ import type { TeamNetworkServer } from "../manifest/index.js";
2
+ export interface MoltnetSecretPatch {
3
+ envName: string;
4
+ jsonPath: string;
5
+ }
6
+ export interface MoltnetClientAuthPlan {
7
+ mode: "bearer" | "none" | "open";
8
+ staticToken?: boolean;
9
+ tokenEnv?: string;
10
+ tokenPath?: string;
11
+ }
12
+ export interface MoltnetNativeRoomConfig {
13
+ id: string;
14
+ members: string[];
15
+ name?: string;
16
+ }
17
+ export interface MoltnetNativeServerConfigInput {
18
+ networkId: string;
19
+ networkName: string;
20
+ rooms: MoltnetNativeRoomConfig[];
21
+ server: Extract<TeamNetworkServer, {
22
+ mode: "managed";
23
+ }>;
24
+ }
25
+ export declare const createMoltnetOpenTokenPath: (networkId: string, memberId: string) => string;
26
+ export declare const createMoltnetServerConfigPath: (serverId: string) => string;
27
+ export declare const createMoltnetNodeConfigPath: (teamSlug: string, networkId: string, agentId: string) => string;
28
+ export declare const renderMoltnetListenAddr: (server: Extract<TeamNetworkServer, {
29
+ mode: "managed";
30
+ }>) => string;
31
+ export declare const resolveMoltnetBaseUrl: (server: TeamNetworkServer) => string;
32
+ export declare const resolveMoltnetClientAuth: (server: TeamNetworkServer, networkId: string, memberId: string) => MoltnetClientAuthPlan;
33
+ export declare const createMoltnetNativeServerConfig: ({ networkId, networkName, rooms, server }: MoltnetNativeServerConfigInput) => {
34
+ config: Record<string, unknown>;
35
+ secretPatches: MoltnetSecretPatch[];
36
+ };
@@ -0,0 +1,125 @@
1
+ const pathSafeSegment = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "item";
2
+ const isIpv6Literal = (value) => value.includes(":");
3
+ export const createMoltnetOpenTokenPath = (networkId, memberId) => `/var/lib/spawnfile/moltnet/tokens/${pathSafeSegment(networkId)}/${pathSafeSegment(memberId)}.token`;
4
+ export const createMoltnetServerConfigPath = (serverId) => `container/rootfs/var/lib/spawnfile/moltnet/servers/${pathSafeSegment(serverId)}/Moltnet.json`;
5
+ export const createMoltnetNodeConfigPath = (teamSlug, networkId, agentId) => `container/rootfs/var/lib/spawnfile/moltnet/nodes/${pathSafeSegment(teamSlug)}-${pathSafeSegment(networkId)}-${pathSafeSegment(agentId)}.json`;
6
+ export const renderMoltnetListenAddr = (server) => {
7
+ const bind = server.listen.bind;
8
+ return `${isIpv6Literal(bind) ? `[${bind}]` : bind}:${server.listen.port}`;
9
+ };
10
+ export const resolveMoltnetBaseUrl = (server) => {
11
+ if (server.mode === "external") {
12
+ return server.url;
13
+ }
14
+ if (server.url && server.url.trim().length > 0) {
15
+ return server.url.trim();
16
+ }
17
+ const bind = server.listen.bind;
18
+ const host = bind === "0.0.0.0" || bind === "::"
19
+ ? "127.0.0.1"
20
+ : isIpv6Literal(bind)
21
+ ? `[${bind}]`
22
+ : bind;
23
+ return `http://${host}:${server.listen.port}`;
24
+ };
25
+ export const resolveMoltnetClientAuth = (server, networkId, memberId) => {
26
+ if (server.auth.mode === "none") {
27
+ return { mode: "none" };
28
+ }
29
+ const client = server.auth.client;
30
+ if (server.auth.mode === "open" && !client) {
31
+ return {
32
+ mode: "open",
33
+ tokenPath: createMoltnetOpenTokenPath(networkId, memberId)
34
+ };
35
+ }
36
+ if (!client) {
37
+ return { mode: server.auth.mode };
38
+ }
39
+ const tokenEnv = client.token_env
40
+ ?? (client.token_id && server.mode === "managed"
41
+ ? server.auth.tokens?.find((token) => token.id === client.token_id)?.secret
42
+ : undefined);
43
+ return {
44
+ mode: server.auth.mode,
45
+ ...(client.static_token ? { staticToken: true } : {}),
46
+ ...(tokenEnv ? { tokenEnv } : {}),
47
+ ...(client.token_path ? { tokenPath: client.token_path } : {})
48
+ };
49
+ };
50
+ const storageConfigFor = (store) => {
51
+ switch (store.kind) {
52
+ case "sqlite":
53
+ return { kind: "sqlite", sqlite: { path: store.path } };
54
+ case "json":
55
+ return { kind: "json", json: { path: store.path } };
56
+ case "postgres":
57
+ return { kind: "postgres", postgres: { dsn: "" } };
58
+ case "memory":
59
+ return { kind: "memory" };
60
+ }
61
+ };
62
+ export const createMoltnetNativeServerConfig = ({ networkId, networkName, rooms, server }) => {
63
+ const secretPatches = [];
64
+ const tokens = (server.auth.tokens ?? []).map((token, index) => {
65
+ secretPatches.push({
66
+ envName: token.secret,
67
+ jsonPath: `auth.tokens.${index}.value`
68
+ });
69
+ return {
70
+ id: token.id,
71
+ value: "",
72
+ scopes: token.scopes,
73
+ ...(token.agents ? { agents: token.agents } : {})
74
+ };
75
+ });
76
+ const pairings = (server.pairings ?? []).map((pairing, index) => {
77
+ secretPatches.push({
78
+ envName: pairing.token_secret,
79
+ jsonPath: `pairings.${index}.token`
80
+ });
81
+ return {
82
+ id: pairing.id,
83
+ remote_network_id: pairing.remote_network_id,
84
+ remote_network_name: pairing.remote_network_name,
85
+ remote_base_url: pairing.remote_base_url,
86
+ token: ""
87
+ };
88
+ });
89
+ if (server.store.kind === "postgres") {
90
+ secretPatches.push({
91
+ envName: server.store.dsn_secret,
92
+ jsonPath: "storage.postgres.dsn"
93
+ });
94
+ }
95
+ return {
96
+ config: {
97
+ version: "moltnet.v1",
98
+ network: {
99
+ id: networkId,
100
+ name: networkName
101
+ },
102
+ server: {
103
+ listen_addr: renderMoltnetListenAddr(server),
104
+ ...(server.human_ingress !== undefined ? { human_ingress: server.human_ingress } : {}),
105
+ ...(server.direct_messages !== undefined ? { direct_messages: server.direct_messages } : {}),
106
+ ...(server.trust_forwarded_proto !== undefined
107
+ ? { trust_forwarded_proto: server.trust_forwarded_proto }
108
+ : {}),
109
+ ...(server.allowed_origins ? { allowed_origins: server.allowed_origins } : {})
110
+ },
111
+ auth: {
112
+ mode: server.auth.mode,
113
+ ...(tokens.length > 0 ? { tokens } : {})
114
+ },
115
+ storage: storageConfigFor(server.store),
116
+ rooms: rooms.map((room) => ({
117
+ id: room.id,
118
+ ...(room.name ? { name: room.name } : {}),
119
+ members: room.members
120
+ })),
121
+ ...(pairings.length > 0 ? { pairings } : {})
122
+ },
123
+ secretPatches
124
+ };
125
+ };
@@ -0,0 +1,2 @@
1
+ import type { CompilePlan, ResolvedAgentNode } from "./types.js";
2
+ export declare const resolveRuntimeConfig: (plan: CompilePlan, agentNode: ResolvedAgentNode, nodeSlug: string, networkId: string, agentId: string) => Record<string, string>;
@@ -0,0 +1,69 @@
1
+ import { getRuntimeAdapter } from "../runtime/index.js";
2
+ import { SpawnfileError } from "../shared/index.js";
3
+ const DEFAULT_TINYCLAW_PORT = 3777;
4
+ const INSTANCE_ROOT_PLACEHOLDER = "<instance-root>";
5
+ const CONFIG_FILE_PLACEHOLDER = "<config-file>";
6
+ const resolveSequentialRuntimePort = (plan, runtimeName, slug) => {
7
+ const adapter = getRuntimeAdapter(runtimeName);
8
+ const basePort = adapter.container.port;
9
+ if (basePort === undefined) {
10
+ return undefined;
11
+ }
12
+ const runtimeAgents = plan.nodes.filter((node) => node.kind === "agent" && node.runtimeName === runtimeName);
13
+ const index = runtimeAgents.findIndex((node) => node.slug === slug);
14
+ if (index < 0) {
15
+ return undefined;
16
+ }
17
+ return basePort + (index * (adapter.container.portStride ?? 1));
18
+ };
19
+ const createTinyClawChannel = (networkId, agentId) => `moltnet:${networkId}:${agentId}`;
20
+ const replaceContainerPathTemplate = (template, instanceRoot, configFileName) => template
21
+ .replaceAll(INSTANCE_ROOT_PLACEHOLDER, instanceRoot)
22
+ .replaceAll(CONFIG_FILE_PLACEHOLDER, configFileName);
23
+ const resolveRuntimeInstancePaths = (runtimeName, slug) => {
24
+ const adapter = getRuntimeAdapter(runtimeName);
25
+ const instanceRoot = `/var/lib/spawnfile/instances/${runtimeName}/agent-${slug}`;
26
+ return {
27
+ configPath: replaceContainerPathTemplate(adapter.container.instancePaths.configPathTemplate, instanceRoot, adapter.container.configFileName),
28
+ homePath: adapter.container.instancePaths.homePathTemplate
29
+ ? replaceContainerPathTemplate(adapter.container.instancePaths.homePathTemplate, instanceRoot, adapter.container.configFileName)
30
+ : undefined
31
+ };
32
+ };
33
+ export const resolveRuntimeConfig = (plan, agentNode, nodeSlug, networkId, agentId) => {
34
+ switch (agentNode.runtime.name) {
35
+ case "openclaw": {
36
+ const port = resolveSequentialRuntimePort(plan, "openclaw", nodeSlug);
37
+ if (!port) {
38
+ throw new SpawnfileError("compile_error", `Unable to resolve OpenClaw gateway port for Moltnet agent ${agentNode.name}`);
39
+ }
40
+ const instancePaths = resolveRuntimeInstancePaths("openclaw", nodeSlug);
41
+ return {
42
+ gateway_url: `ws://127.0.0.1:${port}`,
43
+ ...(instancePaths.homePath ? { home_path: instancePaths.homePath } : {}),
44
+ kind: "openclaw"
45
+ };
46
+ }
47
+ case "picoclaw": {
48
+ const instancePaths = resolveRuntimeInstancePaths("picoclaw", nodeSlug);
49
+ return {
50
+ command: "/usr/local/bin/picoclaw",
51
+ config_path: instancePaths.configPath,
52
+ ...(instancePaths.homePath ? { home_path: instancePaths.homePath } : {}),
53
+ kind: "picoclaw"
54
+ };
55
+ }
56
+ case "tinyclaw": {
57
+ const channel = createTinyClawChannel(networkId, agentId);
58
+ return {
59
+ ack_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/responses`,
60
+ channel,
61
+ inbound_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/message`,
62
+ kind: "tinyclaw",
63
+ outbound_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/responses/pending?channel=${encodeURIComponent(channel)}`
64
+ };
65
+ }
66
+ default:
67
+ throw new SpawnfileError("compile_error", `Moltnet does not know how to attach runtime ${agentNode.runtime.name} directly`);
68
+ }
69
+ };
@@ -16,6 +16,7 @@ export interface RunProjectOptions extends CompileProjectOptions {
16
16
  containerName?: string;
17
17
  detach?: boolean;
18
18
  dockerCommand?: string;
19
+ envFilePath?: string;
19
20
  imageTag?: string;
20
21
  runRunner?: DockerRunRunner;
21
22
  }
@@ -29,6 +30,7 @@ export declare const createDockerRunInvocation: (compileResult: CompileProjectRe
29
30
  containerName?: string;
30
31
  detach?: boolean;
31
32
  dockerCommand?: string;
33
+ envFilePath?: string;
32
34
  }) => Promise<DockerRunInvocation>;
33
35
  export declare const runDockerContainer: DockerRunRunner;
34
36
  export declare const runProject: (inputPath: string, options?: RunProjectOptions) => Promise<RunProjectResult>;
@@ -3,8 +3,8 @@ import path from "node:path";
3
3
  import { mkdtemp } from "node:fs/promises";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { spawn } from "node:child_process";
6
- import { requireAuthProfile } from "../auth/index.js";
7
- import { ensureDirectory, fileExists, removeDirectory, writeUtf8File } from "../filesystem/index.js";
6
+ import { parseEnvFile, requireAuthProfile } from "../auth/index.js";
7
+ import { ensureDirectory, fileExists, readUtf8File, removeDirectory, writeUtf8File } from "../filesystem/index.js";
8
8
  import { SpawnfileError } from "../shared/index.js";
9
9
  import { compileProject } from "./compileProject.js";
10
10
  import { createDefaultImageTag } from "./buildProject.js";
@@ -47,9 +47,10 @@ const collectMissingRequiredSecrets = (containerReport, env, coveredModelSecrets
47
47
  }
48
48
  return [...missing].sort();
49
49
  };
50
- const resolveRunEnvironment = (containerReport, authProfile) => {
50
+ const resolveRunEnvironment = (containerReport, authProfile, envFileEnv = {}) => {
51
51
  const env = {
52
- ...(authProfile?.env ?? {})
52
+ ...(authProfile?.env ?? {}),
53
+ ...envFileEnv
53
54
  };
54
55
  for (const name of new Set([...Object.keys(env), ...containerReport.secrets_required])) {
55
56
  const processValue = process.env[name];
@@ -83,6 +84,18 @@ const renderDockerEnvFile = (env) => `${Object.entries(env)
83
84
  .sort(([left], [right]) => left.localeCompare(right))
84
85
  .map(([name, value]) => `${name}=${value}`)
85
86
  .join("\n")}\n`;
87
+ const readRunEnvFile = async (envFilePath) => {
88
+ if (!envFilePath) {
89
+ return {};
90
+ }
91
+ try {
92
+ return parseEnvFile(await readUtf8File(envFilePath));
93
+ }
94
+ catch (error) {
95
+ const reason = error instanceof Error ? error.message : String(error);
96
+ throw new SpawnfileError("validation_error", `Unable to read env file ${envFilePath}: ${reason}`);
97
+ }
98
+ };
86
99
  const resolveAuthMountArgs = async (containerReport, authProfile) => {
87
100
  if (!authProfile || containerReport.runtime_homes.length === 0) {
88
101
  return [];
@@ -110,7 +123,7 @@ export const createDockerRunInvocation = async (compileResult, imageTag, options
110
123
  const envFilePath = path.join(supportDirectory, "run.env");
111
124
  try {
112
125
  assertDeclaredModelAuthSatisfied(containerReport, options.authProfile ?? null);
113
- const env = resolveRunEnvironment(containerReport, options.authProfile ?? null);
126
+ const env = resolveRunEnvironment(containerReport, options.authProfile ?? null, await readRunEnvFile(options.envFilePath));
114
127
  const preparedRuntimeAuth = await prepareRuntimeAuthMounts(compileResult.outputDirectory, containerReport, options.authProfile ?? null, env, supportDirectory);
115
128
  assertRunEnvironmentSatisfied(containerReport, env, preparedRuntimeAuth.coveredModelSecrets);
116
129
  await ensureDirectory(supportDirectory);
@@ -178,7 +191,8 @@ export const runProject = async (inputPath, options = {}) => {
178
191
  authProfile,
179
192
  containerName: options.containerName,
180
193
  detach: options.detach,
181
- dockerCommand: options.dockerCommand
194
+ dockerCommand: options.dockerCommand,
195
+ envFilePath: options.envFilePath
182
196
  });
183
197
  try {
184
198
  await (options.runRunner ?? runDockerContainer)(invocation);