spawnfile 0.1.5 → 0.1.7

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.
package/README.md CHANGED
@@ -24,6 +24,7 @@ Pairs with [**Moltnet**](https://moltnet.dev) as the first provider for `team.ne
24
24
 
25
25
  ```bash
26
26
  npm install -g spawnfile
27
+ spawnfile --version
27
28
  spawnfile --help
28
29
  ```
29
30
 
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { Command } from "commander";
2
3
  import { importClaudeCodeAuth, importCodexAuth, importEnvFile, requireAuthProfile } from "../auth/index.js";
3
4
  import { addAgentProject, addProjectSurface, addProjectModelFallback, addSubagentProject, addTeamProject, buildOrganizationView, buildCompilePlan, buildProject, clearProjectModelFallbacks, compileProject, initProject, upProject, removeProjectSurface, runProject, setProjectPrimaryModel, setProjectRuntime, setProjectSurfaceAccess, showProjectSurfaces, syncProjectAuth } from "../compiler/index.js";
@@ -8,6 +9,11 @@ import { registerModelCommands } from "./modelCommands.js";
8
9
  import { registerRuntimeCommands } from "./runtimeCommands.js";
9
10
  import { registerSurfaceCommands } from "./surfaceCommands.js";
10
11
  import { registerViewCommand } from "./viewCommand.js";
12
+ const packageJsonPath = new URL("../../package.json", import.meta.url);
13
+ const readPackageVersion = () => {
14
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
15
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
16
+ };
11
17
  const createDefaultStreams = () => ({
12
18
  stderr: (message) => process.stderr.write(`${message}\n`),
13
19
  stdout: (message) => process.stdout.write(`${message}\n`)
@@ -77,7 +83,7 @@ export const runCli = async (argv, optionsOrStreams, handlerOverrides = {}) => {
77
83
  const streams = cliOptions.streams;
78
84
  const handlers = { ...createDefaultHandlers(), ...cliOptions.handlers };
79
85
  const program = new Command();
80
- program.name("spawnfile").description("Spawnfile v0.1 compiler");
86
+ program.name("spawnfile").description("Spawnfile v0.1 compiler").version(readPackageVersion());
81
87
  program.exitOverride();
82
88
  program.configureOutput({
83
89
  outputError: (message, write) => write(message),
@@ -15,6 +15,14 @@ export const createContainerArtifacts = async (plan, compiledNodes, options = {}
15
15
  .filter((variable) => variable.required && variable.categories.includes("runtime"))
16
16
  .map((variable) => variable.name)
17
17
  .sort();
18
+ const persistentMounts = (options.moltnet?.persistentMounts ?? [])
19
+ .map((mount) => ({
20
+ id: mount.id,
21
+ mount_path: mount.mountPath,
22
+ reason: mount.reason,
23
+ volume_name: mount.volumeName
24
+ }))
25
+ .sort((left, right) => left.id.localeCompare(right.id));
18
26
  const files = [
19
27
  ...createRootfsFiles(runtimePlans),
20
28
  ...(options.moltnet?.files ?? []),
@@ -22,7 +30,8 @@ export const createContainerArtifacts = async (plan, compiledNodes, options = {}
22
30
  content: await renderDockerfile(runtimePlans, {
23
31
  hasMoltnet: Boolean(options.moltnet),
24
32
  hasStagedMoltnetBinaries: options.hasStagedMoltnetBinaries,
25
- moltnetPublishedPorts: options.moltnet?.publishedPorts ?? []
33
+ moltnetPublishedPorts: options.moltnet?.publishedPorts ?? [],
34
+ persistentMountPaths: persistentMounts.map((mount) => mount.mount_path)
26
35
  }),
27
36
  path: "Dockerfile"
28
37
  },
@@ -95,6 +104,7 @@ export const createContainerArtifacts = async (plan, compiledNodes, options = {}
95
104
  runtime_secrets_required: runtimeSecretsRequired,
96
105
  runtimes_installed: runtimesInstalled,
97
106
  secrets_required: requiredSecrets,
107
+ ...(persistentMounts.length > 0 ? { persistent_mounts: persistentMounts } : {}),
98
108
  ...(workspaceResources.length > 0 ? { workspace_resources: workspaceResources } : {})
99
109
  }
100
110
  };
@@ -6,12 +6,30 @@ import { MOLTNET_BIN_DIRECTORY, MOLTNET_BINARY_NAMES } from "./moltnetBinaries.j
6
6
  const CONTAINER_ROOTFS_ROOT = "container/rootfs";
7
7
  const GATEWAY_PORT_PLACEHOLDER = "<gateway-port>";
8
8
  const MOLTNET_INSTALL_SCRIPT_URL = "https://moltnet.dev/install.sh";
9
+ const MOLTNET_RELEASE_METADATA_URL = "https://api.github.com/repos/noopolis/moltnet/releases/latest";
9
10
  const WORKSPACE_PLACEHOLDER = "<workspace-path>";
10
11
  const shellQuote = (value) => `'${value.replace(/'/g, `'\"'\"'`)}'`;
11
12
  const extractNodeMajorVersion = (image) => Number(image.match(/^node:(\d+)/)?.[1] ?? "0");
12
13
  const createPackageInstallCommand = (packages) => `RUN apt-get update && apt-get install -y --no-install-recommends ${packages.join(" ")} && rm -rf /var/lib/apt/lists/*`;
13
14
  const createNpmPackageInstallCommand = (packages) => `RUN npm install -g --omit=dev --no-fund --no-audit ${packages.join(" ")}`;
14
15
  const createPipxPackageInstallCommand = (packages) => `RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install ${packages.join(" ")}`;
16
+ const createStateOwnershipCommand = (persistentMountPaths = []) => {
17
+ const mountPaths = [...new Set(persistentMountPaths)].sort();
18
+ const mkdirPaths = [...new Set(["/var/lib/spawnfile", ...mountPaths])].sort();
19
+ const markerCommands = mountPaths.map((mountPath) => `touch ${shellQuote(path.posix.join(mountPath, ".spawnfile-volume-init"))}`);
20
+ const chownPaths = [
21
+ ...new Set([
22
+ "/var/lib/spawnfile",
23
+ "/opt/spawnfile",
24
+ ...mountPaths.filter((mountPath) => !mountPath.startsWith("/var/lib/spawnfile/"))
25
+ ])
26
+ ].sort();
27
+ return [
28
+ `mkdir -p ${mkdirPaths.map(shellQuote).join(" ")}`,
29
+ ...markerCommands,
30
+ `chown -R spawnfile:spawnfile ${chownPaths.map(shellQuote).join(" ")}`
31
+ ].join(" && ");
32
+ };
15
33
  const dedupePackages = (packages) => {
16
34
  const seen = new Map();
17
35
  for (const currentPackage of packages) {
@@ -163,7 +181,7 @@ export const renderDockerfile = async (runtimePlans, options = {}) => {
163
181
  lines.push(`COPY ${MOLTNET_BIN_DIRECTORY}/ /usr/local/bin/`, `RUN chmod +x ${MOLTNET_BINARY_NAMES.map((binaryName) => `/usr/local/bin/${binaryName}`).join(" ")}`, "");
164
182
  }
165
183
  else if (options.hasMoltnet) {
166
- lines.push(`RUN MOLTNET_INSTALL_DIR=/usr/local/bin sh -c ${shellQuote(`curl -fsSL ${MOLTNET_INSTALL_SCRIPT_URL} | sh`)}`, "");
184
+ lines.push(`ADD ${MOLTNET_RELEASE_METADATA_URL} /tmp/spawnfile-moltnet-release.json`, `RUN MOLTNET_RELEASE="$${"("}sed -n 's/.*\\"tag_name\\": *\\"\\([^\\"]*\\)\\".*/\\1/p' /tmp/spawnfile-moltnet-release.json | head -n 1${")"}" && echo "Installing Moltnet $${"{MOLTNET_RELEASE:-latest}"}" && MOLTNET_INSTALL_DIR=/usr/local/bin sh -c ${shellQuote(`curl -fsSL ${MOLTNET_INSTALL_SCRIPT_URL} | sh`)}`, "");
167
185
  }
168
186
  for (const recipe of runtimeRecipes) {
169
187
  for (const copyCommand of recipe.copyCommands) {
@@ -176,7 +194,7 @@ export const renderDockerfile = async (runtimePlans, options = {}) => {
176
194
  }
177
195
  lines.push('RUN if ! id -u spawnfile >/dev/null 2>&1; then useradd --create-home --home-dir /home/spawnfile --shell /bin/bash spawnfile; fi', "");
178
196
  lines.push("COPY container/rootfs/ /", "COPY .env.example /opt/spawnfile/.env.example", 'COPY entrypoint.sh /opt/spawnfile/entrypoint.sh', "RUN chmod +x /opt/spawnfile/entrypoint.sh");
179
- lines.push("RUN mkdir -p /var/lib/spawnfile && chown -R spawnfile:spawnfile /var/lib/spawnfile /opt/spawnfile");
197
+ lines.push(`RUN ${createStateOwnershipCommand(options.persistentMountPaths)}`);
180
198
  if (exposedPorts.length > 0) {
181
199
  lines.push(`EXPOSE ${exposedPorts.join(" ")}`);
182
200
  }
@@ -8,5 +8,6 @@ export interface EntrypointOptions {
8
8
  serverPlans: MoltnetArtifacts["serverPlans"];
9
9
  };
10
10
  moltnetPublishedPorts?: number[];
11
+ persistentMountPaths?: string[];
11
12
  }
12
13
  export declare const renderEntrypoint: (runtimePlans: RuntimeTargetPlan[], requiredSecrets: string[], options?: EntrypointOptions) => string;
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { resolveMoltnetStorePath } from "./moltnetConfigLowering.js";
2
3
  import { createWorkspaceResourceCommands, createWorkspaceResourceShellFunctions } from "./containerWorkspaceResourceRender.js";
3
4
  const MOLTNET_SERVER_DATA_DIRECTORY = "/var/lib/spawnfile/moltnet/servers";
4
5
  const shellQuote = (value) => `'${value.replace(/'/g, `'\"'\"'`)}'`;
@@ -36,6 +37,17 @@ const createAuthSetupCommands = (plan) => {
36
37
  }
37
38
  return [`configure_codex_api_key_auth ${shellQuote(plan.instancePaths.homePath)}`];
38
39
  };
40
+ const createMoltnetStorePrepareCommands = (serverPlan) => {
41
+ const server = serverPlan.server;
42
+ if (server.mode !== "managed" || server.store.kind === "memory" || server.store.kind === "postgres") {
43
+ return [];
44
+ }
45
+ const storePath = resolveMoltnetStorePath(serverPlan.networkId, server.store);
46
+ if (!storePath) {
47
+ return [];
48
+ }
49
+ return [`mkdir -p ${shellQuote(path.posix.dirname(storePath))}`];
50
+ };
39
51
  const resolveStartCommand = (plan) => plan.meta.startCommand
40
52
  .map((token) => token
41
53
  .replaceAll("<runtime-root>", plan.runtimeRoot)
@@ -174,7 +186,7 @@ export const renderEntrypoint = (runtimePlans, requiredSecrets, options = {}) =>
174
186
  for (const patch of serverPlan.secretPatches) {
175
187
  lines.push(`apply_json_env_value ${shellQuote(serverPlan.configPath)} ${shellQuote(patch.envName)} ${shellQuote(patch.jsonPath)}`);
176
188
  }
177
- lines.push(`MOLTNET_CONFIG=${shellQuote(serverPlan.configPath)} /usr/local/bin/moltnet &`, 'PIDS+=("$!")', "");
189
+ lines.push(...createMoltnetStorePrepareCommands(serverPlan), `MOLTNET_CONFIG=${shellQuote(serverPlan.configPath)} /usr/local/bin/moltnet &`, 'PIDS+=("$!")', "");
178
190
  }
179
191
  for (const serverPlan of managedMoltnetServerPlans) {
180
192
  if (!serverPlan.port) {
@@ -23,9 +23,16 @@ export interface MoltnetNodePlan {
23
23
  configPath: string;
24
24
  networkId: string;
25
25
  }
26
+ export interface MoltnetPersistentMount {
27
+ id: string;
28
+ mountPath: string;
29
+ reason: string;
30
+ volumeName: string;
31
+ }
26
32
  export interface MoltnetArtifacts {
27
33
  files: EmittedFile[];
28
34
  nodePlans: MoltnetNodePlan[];
35
+ persistentMounts: MoltnetPersistentMount[];
29
36
  ports: number[];
30
37
  publishedPorts: number[];
31
38
  serverPlans: MoltnetServerPlan[];
@@ -1,10 +1,20 @@
1
1
  import { SpawnfileError } from "../shared/index.js";
2
- import { createMoltnetNativeServerConfig, createMoltnetNodeConfigPath, createMoltnetServerConfigPath, resolveMoltnetBaseUrl, resolveMoltnetClientAuth } from "./moltnetConfigLowering.js";
2
+ import { createMoltnetOpenTokenDirectory, createMoltnetNativeServerConfig, createMoltnetNodeConfigPath, createMoltnetServerConfigPath, resolveMoltnetBaseUrl, resolveMoltnetClientAuth, resolveMoltnetStorePersistenceMountPath } from "./moltnetConfigLowering.js";
3
3
  import { listConcreteMoltnetRoomMemberIds } from "./moltnetRoomMemberships.js";
4
4
  import { resolveRuntimeConfig } from "./moltnetRuntimeConfig.js";
5
+ import { createShortHash, slugify } from "./helpers.js";
5
6
  const DEFAULT_MOLTNET_PORT = 8787;
6
7
  const ROOTFS_PREFIX = "container/rootfs";
7
8
  const createServerKey = (networkId) => networkId;
9
+ const truncateSegment = (value, maxLength) => value.length > maxLength ? value.slice(0, maxLength).replace(/[-_.]+$/u, "") : value;
10
+ const createPersistentVolumeName = (planRoot, id, explicitName) => {
11
+ if (explicitName && explicitName.trim().length > 0) {
12
+ return explicitName.trim();
13
+ }
14
+ const project = truncateSegment(slugify(planRoot.split("/").slice(-2, -1)[0] ?? "project") || "project", 32);
15
+ const suffix = truncateSegment(slugify(id) || "state", 48);
16
+ return `spawnfile-${project}-${suffix}-${createShortHash(`${planRoot}:${id}`)}`;
17
+ };
8
18
  const toContainerRootfsPath = (rootfsPath) => `/${rootfsPath.replace(`${ROOTFS_PREFIX}/`, "")}`;
9
19
  const isNetworkHttpEnabled = (network) => network.server?.mode === "managed" && network.server.human_ingress === true;
10
20
  const resolveNetworkPort = (network, fallbackPort) => network.server?.mode === "managed" ? network.server.listen.port : fallbackPort;
@@ -78,6 +88,17 @@ export const generateMoltnetArtifacts = async (plan) => {
78
88
  const nodePlans = [];
79
89
  const nodePlanKeys = new Set();
80
90
  const configFiles = [];
91
+ const persistentMounts = new Map();
92
+ const addPersistentMount = (mount) => {
93
+ const existing = persistentMounts.get(mount.id);
94
+ if (!existing) {
95
+ persistentMounts.set(mount.id, mount);
96
+ return;
97
+ }
98
+ if (existing.mountPath !== mount.mountPath || existing.volumeName !== mount.volumeName) {
99
+ throw new SpawnfileError("validation_error", `Moltnet persistent mount ${mount.id} resolves to conflicting targets`);
100
+ }
101
+ };
81
102
  for (const teamNode of teamNodes) {
82
103
  for (const network of teamNode.value.networks ?? []) {
83
104
  const serverPlan = serverPlans.get(createServerKey(network.id));
@@ -96,6 +117,20 @@ export const generateMoltnetArtifacts = async (plan) => {
96
117
  mode: 0o600,
97
118
  path: createMoltnetServerConfigPath(serverPlan.id)
98
119
  });
120
+ const storeMountPath = resolveMoltnetStorePersistenceMountPath(network.id, network.server.store);
121
+ if (storeMountPath) {
122
+ const mountId = `moltnet-${network.id}-store`;
123
+ const store = network.server.store;
124
+ const volumeName = store.kind === "sqlite" || store.kind === "json"
125
+ ? store.persistence?.name
126
+ : undefined;
127
+ addPersistentMount({
128
+ id: mountId,
129
+ mountPath: storeMountPath,
130
+ reason: `managed Moltnet ${network.server.store.kind} store for ${network.id}`,
131
+ volumeName: createPersistentVolumeName(plan.root, mountId, volumeName)
132
+ });
133
+ }
99
134
  }
100
135
  }
101
136
  for (const node of plan.nodes) {
@@ -134,17 +169,28 @@ export const generateMoltnetArtifacts = async (plan) => {
134
169
  throw new SpawnfileError("validation_error", `Duplicate Moltnet node attachment for ${attachment.network}/${attachment.memberId}`);
135
170
  }
136
171
  nodePlanKeys.add(nodePlanKey);
137
- const clientAuth = resolveMoltnetClientAuth(network.server, attachment.network, attachment.memberId);
172
+ const clientAuth = resolveMoltnetClientAuth(network.server, attachment.network, attachment.memberId, node.slug);
138
173
  const usesPerAttachmentOpenToken = clientAuth.mode === "open" &&
139
174
  clientAuth.staticToken !== true &&
140
175
  Boolean(clientAuth.tokenEnv || clientAuth.tokenPath);
176
+ if (usesPerAttachmentOpenToken && clientAuth.tokenPath) {
177
+ const mountId = `agent-${node.slug}-moltnet-tokens`;
178
+ addPersistentMount({
179
+ id: mountId,
180
+ mountPath: createMoltnetOpenTokenDirectory(node.slug),
181
+ reason: `Moltnet open-mode generated agent tokens for ${agentNode.name}`,
182
+ volumeName: createPersistentVolumeName(plan.root, mountId)
183
+ });
184
+ }
141
185
  configFiles.push({
142
186
  content: `${JSON.stringify({
143
187
  version: "moltnet.node.v1",
144
188
  moltnet: {
145
189
  base_url: serverPlan.baseUrl,
146
190
  network_id: attachment.network,
147
- auth_mode: clientAuth.mode,
191
+ ...(clientAuth.mode === "none"
192
+ ? {}
193
+ : { auth_mode: clientAuth.mode }),
148
194
  ...(clientAuth.staticToken
149
195
  ? { static_token: true }
150
196
  : {}),
@@ -210,6 +256,7 @@ export const generateMoltnetArtifacts = async (plan) => {
210
256
  return {
211
257
  files: configFiles,
212
258
  nodePlans: nodePlans.sort((left, right) => left.configPath.localeCompare(right.configPath)),
259
+ persistentMounts: [...persistentMounts.values()].sort((left, right) => left.id.localeCompare(right.id)),
213
260
  ports: [...new Set(managedServerPlans.map((serverPlan) => serverPlan.port).filter((port) => port !== undefined))].sort((left, right) => left - right),
214
261
  publishedPorts: [
215
262
  ...new Set(teamNodes
@@ -22,15 +22,24 @@ export interface MoltnetNativeServerConfigInput {
22
22
  mode: "managed";
23
23
  }>;
24
24
  }
25
- export declare const createMoltnetOpenTokenPath: (networkId: string, memberId: string) => string;
25
+ type ManagedMoltnetStore = Extract<TeamNetworkServer, {
26
+ mode: "managed";
27
+ }>["store"];
28
+ export declare const createMoltnetOpenTokenDirectory: (agentSlug: string) => string;
29
+ export declare const createMoltnetOpenTokenPath: (networkId: string, memberId: string, agentSlug?: string) => string;
30
+ export declare const createMoltnetNetworkStateDirectory: (networkId: string) => string;
31
+ export declare const createDefaultMoltnetStorePath: (networkId: string, kind: "json" | "sqlite", mountPath?: string) => string;
32
+ export declare const resolveMoltnetStorePath: (networkId: string, store: ManagedMoltnetStore) => string | null;
33
+ export declare const resolveMoltnetStorePersistenceMountPath: (networkId: string, store: ManagedMoltnetStore) => string | null;
26
34
  export declare const createMoltnetServerConfigPath: (serverId: string) => string;
27
35
  export declare const createMoltnetNodeConfigPath: (teamSlug: string, networkId: string, agentId: string) => string;
28
36
  export declare const renderMoltnetListenAddr: (server: Extract<TeamNetworkServer, {
29
37
  mode: "managed";
30
38
  }>) => string;
31
39
  export declare const resolveMoltnetBaseUrl: (server: TeamNetworkServer) => string;
32
- export declare const resolveMoltnetClientAuth: (server: TeamNetworkServer, networkId: string, memberId: string) => MoltnetClientAuthPlan;
40
+ export declare const resolveMoltnetClientAuth: (server: TeamNetworkServer, networkId: string, memberId: string, agentSlug?: string) => MoltnetClientAuthPlan;
33
41
  export declare const createMoltnetNativeServerConfig: ({ networkId, networkName, rooms, server }: MoltnetNativeServerConfigInput) => {
34
42
  config: Record<string, unknown>;
35
43
  secretPatches: MoltnetSecretPatch[];
36
44
  };
45
+ export {};
@@ -1,6 +1,34 @@
1
1
  const pathSafeSegment = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "item";
2
2
  const isIpv6Literal = (value) => value.includes(":");
3
- export const createMoltnetOpenTokenPath = (networkId, memberId) => `/var/lib/spawnfile/moltnet/tokens/${pathSafeSegment(networkId)}/${pathSafeSegment(memberId)}.token`;
3
+ const normalizePosixPath = (value) => value.replace(/\/+/g, "/").replace(/\/+$/u, "") || "/";
4
+ export const createMoltnetOpenTokenDirectory = (agentSlug) => `/var/lib/spawnfile/agents/${pathSafeSegment(agentSlug)}/state/moltnet`;
5
+ export const createMoltnetOpenTokenPath = (networkId, memberId, agentSlug = memberId) => `${createMoltnetOpenTokenDirectory(agentSlug)}/${pathSafeSegment(networkId)}-${pathSafeSegment(memberId)}.token`;
6
+ export const createMoltnetNetworkStateDirectory = (networkId) => `/var/lib/spawnfile/moltnet/networks/${pathSafeSegment(networkId)}`;
7
+ export const createDefaultMoltnetStorePath = (networkId, kind, mountPath) => {
8
+ const directory = mountPath
9
+ ? normalizePosixPath(mountPath)
10
+ : createMoltnetNetworkStateDirectory(networkId);
11
+ return `${directory}/${kind === "sqlite" ? "moltnet.sqlite" : "state.json"}`;
12
+ };
13
+ export const resolveMoltnetStorePath = (networkId, store) => {
14
+ if (store.kind !== "sqlite" && store.kind !== "json") {
15
+ return null;
16
+ }
17
+ return store.path ?? createDefaultMoltnetStorePath(networkId, store.kind, store.persistence?.mount);
18
+ };
19
+ export const resolveMoltnetStorePersistenceMountPath = (networkId, store) => {
20
+ if (store.kind !== "sqlite" && store.kind !== "json") {
21
+ return null;
22
+ }
23
+ if (store.persistence?.mode === "ephemeral") {
24
+ return null;
25
+ }
26
+ if (store.persistence?.mount) {
27
+ return normalizePosixPath(store.persistence.mount);
28
+ }
29
+ const storePath = resolveMoltnetStorePath(networkId, store);
30
+ return storePath ? storePath.slice(0, storePath.lastIndexOf("/")) || "/" : null;
31
+ };
4
32
  export const createMoltnetServerConfigPath = (serverId) => `container/rootfs/var/lib/spawnfile/moltnet/servers/${pathSafeSegment(serverId)}/Moltnet.json`;
5
33
  export const createMoltnetNodeConfigPath = (teamSlug, networkId, agentId) => `container/rootfs/var/lib/spawnfile/moltnet/nodes/${pathSafeSegment(teamSlug)}-${pathSafeSegment(networkId)}-${pathSafeSegment(agentId)}.json`;
6
34
  export const renderMoltnetListenAddr = (server) => {
@@ -22,7 +50,7 @@ export const resolveMoltnetBaseUrl = (server) => {
22
50
  : bind;
23
51
  return `http://${host}:${server.listen.port}`;
24
52
  };
25
- export const resolveMoltnetClientAuth = (server, networkId, memberId) => {
53
+ export const resolveMoltnetClientAuth = (server, networkId, memberId, agentSlug) => {
26
54
  if (server.auth.mode === "none") {
27
55
  return { mode: "none" };
28
56
  }
@@ -30,7 +58,7 @@ export const resolveMoltnetClientAuth = (server, networkId, memberId) => {
30
58
  if (server.auth.mode === "open" && !client) {
31
59
  return {
32
60
  mode: "open",
33
- tokenPath: createMoltnetOpenTokenPath(networkId, memberId)
61
+ tokenPath: createMoltnetOpenTokenPath(networkId, memberId, agentSlug)
34
62
  };
35
63
  }
36
64
  if (!client) {
@@ -47,12 +75,12 @@ export const resolveMoltnetClientAuth = (server, networkId, memberId) => {
47
75
  ...(client.token_path ? { tokenPath: client.token_path } : {})
48
76
  };
49
77
  };
50
- const storageConfigFor = (store) => {
78
+ const storageConfigFor = (networkId, store) => {
51
79
  switch (store.kind) {
52
80
  case "sqlite":
53
- return { kind: "sqlite", sqlite: { path: store.path } };
81
+ return { kind: "sqlite", sqlite: { path: resolveMoltnetStorePath(networkId, store) } };
54
82
  case "json":
55
- return { kind: "json", json: { path: store.path } };
83
+ return { kind: "json", json: { path: resolveMoltnetStorePath(networkId, store) } };
56
84
  case "postgres":
57
85
  return { kind: "postgres", postgres: { dsn: "" } };
58
86
  case "memory":
@@ -112,7 +140,7 @@ export const createMoltnetNativeServerConfig = ({ networkId, networkName, rooms,
112
140
  mode: server.auth.mode,
113
141
  ...(tokens.length > 0 ? { tokens } : {})
114
142
  },
115
- storage: storageConfigFor(server.store),
143
+ storage: storageConfigFor(networkId, server.store),
116
144
  rooms: rooms.map((room) => ({
117
145
  id: room.id,
118
146
  ...(room.name ? { name: room.name } : {}),
@@ -140,6 +140,9 @@ export const createDockerRunInvocation = async (compileResult, imageTag, options
140
140
  for (const port of containerReport.ports) {
141
141
  args.push("-p", `${port}:${port}`);
142
142
  }
143
+ for (const mount of containerReport.persistent_mounts ?? []) {
144
+ args.push("-v", `${mount.volume_name}:${mount.mount_path}`);
145
+ }
143
146
  args.push("--env-file", envFilePath);
144
147
  args.push(...(await resolveAuthMountArgs(containerReport, options.authProfile ?? null)));
145
148
  args.push(...preparedRuntimeAuth.mountArgs);
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
  import { SpawnfileError } from "../shared/index.js";
3
- const INVALID_PATH_SEGMENT = "..";
4
3
  export const assertPortableRelativePath = (inputPath) => {
5
4
  if (inputPath.includes("\\")) {
6
5
  throw new SpawnfileError("validation_error", `Paths must use forward slashes: ${inputPath}`);
@@ -8,10 +7,6 @@ export const assertPortableRelativePath = (inputPath) => {
8
7
  if (path.isAbsolute(inputPath)) {
9
8
  throw new SpawnfileError("validation_error", `Absolute paths are not allowed: ${inputPath}`);
10
9
  }
11
- const segments = inputPath.split("/");
12
- if (segments.includes(INVALID_PATH_SEGMENT)) {
13
- throw new SpawnfileError("validation_error", `Path traversal is not allowed: ${inputPath}`);
14
- }
15
10
  };
16
11
  export const getCanonicalManifestPath = (filePath) => path.resolve(filePath);
17
12
  export const getManifestPath = (inputPath) => path.basename(inputPath) === "Spawnfile"
@@ -21,10 +16,6 @@ export const getProjectRoot = (manifestPath) => path.dirname(manifestPath);
21
16
  export const resolveProjectPath = (manifestPath, relativePath) => {
22
17
  assertPortableRelativePath(relativePath);
23
18
  const projectRoot = getProjectRoot(manifestPath);
24
- const resolved = path.resolve(projectRoot, relativePath);
25
- if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
26
- throw new SpawnfileError("validation_error", `Path escapes project root: ${relativePath}`);
27
- }
28
- return resolved;
19
+ return path.resolve(projectRoot, relativePath);
29
20
  };
30
21
  export const toPosixPath = (value) => value.split(path.sep).join("/");
@@ -10,6 +10,23 @@ const orderTeamAuthPairing = (pairing) => withDefinedEntries([
10
10
  ["remote_network_id", pairing.remote_network_id],
11
11
  ["remote_network_name", pairing.remote_network_name]
12
12
  ]);
13
+ const orderTeamStore = (server) => server.mode === "managed"
14
+ ? withDefinedEntries([
15
+ ["kind", server.store.kind],
16
+ ["path", server.store.kind === "sqlite" || server.store.kind === "json" ? server.store.path : undefined],
17
+ [
18
+ "persistence",
19
+ (server.store.kind === "sqlite" || server.store.kind === "json") && server.store.persistence
20
+ ? withDefinedEntries([
21
+ ["mode", server.store.persistence.mode],
22
+ ["name", server.store.persistence.name],
23
+ ["mount", server.store.persistence.mount]
24
+ ])
25
+ : undefined
26
+ ],
27
+ ["dsn_secret", server.store.kind === "postgres" ? server.store.dsn_secret : undefined]
28
+ ])
29
+ : undefined;
13
30
  const orderTeamAuth = (server) => withDefinedEntries([
14
31
  ["mode", server.mode],
15
32
  ["url", server.url],
@@ -32,7 +49,7 @@ const orderTeamAuth = (server) => withDefinedEntries([
32
49
  ]
33
50
  ])
34
51
  ],
35
- ["store", server.mode === "managed" ? server.store : undefined],
52
+ ["store", orderTeamStore(server)],
36
53
  [
37
54
  "pairings",
38
55
  server.mode === "managed" && server.pairings
@@ -673,11 +673,27 @@ declare const teamManifestSchema: z.ZodObject<{
673
673
  token_secret: z.ZodString;
674
674
  }, z.core.$strict>>>;
675
675
  store: z.ZodDiscriminatedUnion<[z.ZodObject<{
676
+ path: z.ZodOptional<z.ZodString>;
677
+ persistence: z.ZodOptional<z.ZodObject<{
678
+ mode: z.ZodEnum<{
679
+ durable: "durable";
680
+ ephemeral: "ephemeral";
681
+ }>;
682
+ mount: z.ZodOptional<z.ZodString>;
683
+ name: z.ZodOptional<z.ZodString>;
684
+ }, z.core.$strict>>;
676
685
  kind: z.ZodLiteral<"sqlite">;
677
- path: z.ZodString;
678
686
  }, z.core.$strict>, z.ZodObject<{
687
+ path: z.ZodOptional<z.ZodString>;
688
+ persistence: z.ZodOptional<z.ZodObject<{
689
+ mode: z.ZodEnum<{
690
+ durable: "durable";
691
+ ephemeral: "ephemeral";
692
+ }>;
693
+ mount: z.ZodOptional<z.ZodString>;
694
+ name: z.ZodOptional<z.ZodString>;
695
+ }, z.core.$strict>>;
679
696
  kind: z.ZodLiteral<"json">;
680
- path: z.ZodString;
681
697
  }, z.core.$strict>, z.ZodObject<{
682
698
  kind: z.ZodLiteral<"postgres">;
683
699
  dsn_secret: z.ZodString;
@@ -1312,11 +1328,27 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1312
1328
  token_secret: z.ZodString;
1313
1329
  }, z.core.$strict>>>;
1314
1330
  store: z.ZodDiscriminatedUnion<[z.ZodObject<{
1331
+ path: z.ZodOptional<z.ZodString>;
1332
+ persistence: z.ZodOptional<z.ZodObject<{
1333
+ mode: z.ZodEnum<{
1334
+ durable: "durable";
1335
+ ephemeral: "ephemeral";
1336
+ }>;
1337
+ mount: z.ZodOptional<z.ZodString>;
1338
+ name: z.ZodOptional<z.ZodString>;
1339
+ }, z.core.$strict>>;
1315
1340
  kind: z.ZodLiteral<"sqlite">;
1316
- path: z.ZodString;
1317
1341
  }, z.core.$strict>, z.ZodObject<{
1342
+ path: z.ZodOptional<z.ZodString>;
1343
+ persistence: z.ZodOptional<z.ZodObject<{
1344
+ mode: z.ZodEnum<{
1345
+ durable: "durable";
1346
+ ephemeral: "ephemeral";
1347
+ }>;
1348
+ mount: z.ZodOptional<z.ZodString>;
1349
+ name: z.ZodOptional<z.ZodString>;
1350
+ }, z.core.$strict>>;
1318
1351
  kind: z.ZodLiteral<"json">;
1319
- path: z.ZodString;
1320
1352
  }, z.core.$strict>, z.ZodObject<{
1321
1353
  kind: z.ZodLiteral<"postgres">;
1322
1354
  dsn_secret: z.ZodString;
@@ -27,11 +27,27 @@ declare const teamNetworkAuthSchema: z.ZodObject<{
27
27
  }, z.core.$strict>>>;
28
28
  }, z.core.$strict>;
29
29
  declare const teamNetworkStoreSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
30
+ path: z.ZodOptional<z.ZodString>;
31
+ persistence: z.ZodOptional<z.ZodObject<{
32
+ mode: z.ZodEnum<{
33
+ durable: "durable";
34
+ ephemeral: "ephemeral";
35
+ }>;
36
+ mount: z.ZodOptional<z.ZodString>;
37
+ name: z.ZodOptional<z.ZodString>;
38
+ }, z.core.$strict>>;
30
39
  kind: z.ZodLiteral<"sqlite">;
31
- path: z.ZodString;
32
40
  }, z.core.$strict>, z.ZodObject<{
41
+ path: z.ZodOptional<z.ZodString>;
42
+ persistence: z.ZodOptional<z.ZodObject<{
43
+ mode: z.ZodEnum<{
44
+ durable: "durable";
45
+ ephemeral: "ephemeral";
46
+ }>;
47
+ mount: z.ZodOptional<z.ZodString>;
48
+ name: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$strict>>;
33
50
  kind: z.ZodLiteral<"json">;
34
- path: z.ZodString;
35
51
  }, z.core.$strict>, z.ZodObject<{
36
52
  kind: z.ZodLiteral<"postgres">;
37
53
  dsn_secret: z.ZodString;
@@ -80,11 +96,27 @@ declare const teamNetworkServerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
80
96
  token_secret: z.ZodString;
81
97
  }, z.core.$strict>>>;
82
98
  store: z.ZodDiscriminatedUnion<[z.ZodObject<{
99
+ path: z.ZodOptional<z.ZodString>;
100
+ persistence: z.ZodOptional<z.ZodObject<{
101
+ mode: z.ZodEnum<{
102
+ durable: "durable";
103
+ ephemeral: "ephemeral";
104
+ }>;
105
+ mount: z.ZodOptional<z.ZodString>;
106
+ name: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strict>>;
83
108
  kind: z.ZodLiteral<"sqlite">;
84
- path: z.ZodString;
85
109
  }, z.core.$strict>, z.ZodObject<{
110
+ path: z.ZodOptional<z.ZodString>;
111
+ persistence: z.ZodOptional<z.ZodObject<{
112
+ mode: z.ZodEnum<{
113
+ durable: "durable";
114
+ ephemeral: "ephemeral";
115
+ }>;
116
+ mount: z.ZodOptional<z.ZodString>;
117
+ name: z.ZodOptional<z.ZodString>;
118
+ }, z.core.$strict>>;
86
119
  kind: z.ZodLiteral<"json">;
87
- path: z.ZodString;
88
120
  }, z.core.$strict>, z.ZodObject<{
89
121
  kind: z.ZodLiteral<"postgres">;
90
122
  dsn_secret: z.ZodString;
@@ -178,11 +210,27 @@ export declare const teamNetworkSchema: z.ZodObject<{
178
210
  token_secret: z.ZodString;
179
211
  }, z.core.$strict>>>;
180
212
  store: z.ZodDiscriminatedUnion<[z.ZodObject<{
213
+ path: z.ZodOptional<z.ZodString>;
214
+ persistence: z.ZodOptional<z.ZodObject<{
215
+ mode: z.ZodEnum<{
216
+ durable: "durable";
217
+ ephemeral: "ephemeral";
218
+ }>;
219
+ mount: z.ZodOptional<z.ZodString>;
220
+ name: z.ZodOptional<z.ZodString>;
221
+ }, z.core.$strict>>;
181
222
  kind: z.ZodLiteral<"sqlite">;
182
- path: z.ZodString;
183
223
  }, z.core.$strict>, z.ZodObject<{
224
+ path: z.ZodOptional<z.ZodString>;
225
+ persistence: z.ZodOptional<z.ZodObject<{
226
+ mode: z.ZodEnum<{
227
+ durable: "durable";
228
+ ephemeral: "ephemeral";
229
+ }>;
230
+ mount: z.ZodOptional<z.ZodString>;
231
+ name: z.ZodOptional<z.ZodString>;
232
+ }, z.core.$strict>>;
184
233
  kind: z.ZodLiteral<"json">;
185
- path: z.ZodString;
186
234
  }, z.core.$strict>, z.ZodObject<{
187
235
  kind: z.ZodLiteral<"postgres">;
188
236
  dsn_secret: z.ZodString;
@@ -2,6 +2,19 @@ import { z } from "zod";
2
2
  export { teamWorkspaceDocsSchema, teamWorkspaceSchema } from "./workspaceSchemas.js";
3
3
  const moltnetScopeSchema = z.enum(["observe", "write", "admin", "attach", "pair"]);
4
4
  const countTruthy = (value) => value.filter((entry) => Boolean(entry)).length;
5
+ const absolutePosixPathSchema = z
6
+ .string()
7
+ .trim()
8
+ .min(1)
9
+ .refine((value) => value.startsWith("/"), {
10
+ message: "path must be an absolute POSIX path"
11
+ });
12
+ const normalizePosixPath = (value) => value.replace(/\/+/g, "/").replace(/\/+$/u, "") || "/";
13
+ const pathIsInsideMount = (filePath, mountPath) => {
14
+ const normalizedPath = normalizePosixPath(filePath);
15
+ const normalizedMount = normalizePosixPath(mountPath);
16
+ return normalizedPath.startsWith(`${normalizedMount}/`);
17
+ };
5
18
  const teamNetworkAuthTokenSchema = z
6
19
  .object({
7
20
  agents: z.array(z.string().trim().min(1)).optional(),
@@ -91,18 +104,41 @@ const teamNetworkAuthSchema = z
91
104
  }
92
105
  }
93
106
  });
94
- const teamNetworkStoreSqliteSchema = z
107
+ const teamNetworkStorePersistenceSchema = z
95
108
  .object({
96
- kind: z.literal("sqlite"),
97
- path: z.string().trim().min(1)
109
+ mode: z.enum(["durable", "ephemeral"]),
110
+ mount: absolutePosixPathSchema.optional(),
111
+ name: z.string().trim().min(1).optional()
98
112
  })
99
- .strict();
100
- const teamNetworkStoreJsonSchema = z
113
+ .strict()
114
+ .superRefine((value, context) => {
115
+ if (value.mode === "ephemeral" && (value.mount || value.name)) {
116
+ context.addIssue({
117
+ code: z.ZodIssueCode.custom,
118
+ message: "ephemeral persistence must not declare mount or name"
119
+ });
120
+ }
121
+ });
122
+ const teamNetworkFileStoreSchema = z
101
123
  .object({
102
- kind: z.literal("json"),
103
- path: z.string().trim().min(1)
124
+ path: absolutePosixPathSchema.optional(),
125
+ persistence: teamNetworkStorePersistenceSchema.optional()
104
126
  })
105
- .strict();
127
+ .strict()
128
+ .superRefine((value, context) => {
129
+ if (value.path && value.persistence?.mount && !pathIsInsideMount(value.path, value.persistence.mount)) {
130
+ context.addIssue({
131
+ code: z.ZodIssueCode.custom,
132
+ message: "store.path must be inside persistence.mount"
133
+ });
134
+ }
135
+ });
136
+ const teamNetworkStoreSqliteSchema = teamNetworkFileStoreSchema.extend({
137
+ kind: z.literal("sqlite")
138
+ });
139
+ const teamNetworkStoreJsonSchema = teamNetworkFileStoreSchema.extend({
140
+ kind: z.literal("json")
141
+ });
106
142
  const teamNetworkStorePostgresSchema = z
107
143
  .object({
108
144
  kind: z.literal("postgres"),
@@ -27,6 +27,12 @@ export interface ContainerWorkspaceResourceReport {
27
27
  mount: string;
28
28
  sharing: "per_agent" | "team";
29
29
  }
30
+ export interface ContainerPersistentMountReport {
31
+ id: string;
32
+ mount_path: string;
33
+ reason: string;
34
+ volume_name: string;
35
+ }
30
36
  export interface NodeReport {
31
37
  capabilities: CapabilityReport[];
32
38
  diagnostics: DiagnosticReport[];
@@ -49,6 +55,7 @@ export interface ContainerReport {
49
55
  runtime_secrets_required: string[];
50
56
  runtimes_installed: string[];
51
57
  secrets_required: string[];
58
+ persistent_mounts?: ContainerPersistentMountReport[];
52
59
  workspace_resources?: ContainerWorkspaceResourceReport[];
53
60
  }
54
61
  export interface CompileReport {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spawnfile",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Canonical source compiler for autonomous agents and teams.",
5
5
  "license": "MIT",
6
6
  "type": "module",