spawnfile 0.1.3 → 0.1.4

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
@@ -43,6 +43,8 @@ spawnfile run --tag my-agent --auth-profile dev
43
43
 
44
44
  Compiled output lands under `.spawn/` by default, including a `Dockerfile`, `entrypoint.sh`, `.env.example`, and a prebuilt `container/rootfs/` tree. `spawnfile build` uses the pinned runtime artifacts from `runtimes.yaml`; it does not rebuild runtimes from source.
45
45
 
46
+ Declare external credentials in `secrets:` and provide values through an ignored env file or the shell environment. `spawnfile auth sync --env-file .env` stores declared model auth and project secrets in a local auth profile; `spawnfile run --env-file .env` can inject the same values directly for a single run. This is the intended pattern for credentials like `GH_TOKEN`, MCP tokens, and provider API keys.
47
+
46
48
  ## Project structure
47
49
 
48
50
  A Spawnfile project is either an `agent` or a `team`.
@@ -113,12 +113,14 @@ export const runCli = async (argv, optionsOrStreams, handlerOverrides = {}) => {
113
113
  .option("-t, --tag <image>", "Docker image tag")
114
114
  .option("--auth-profile <name>", "Local Spawnfile auth profile")
115
115
  .option("--name <container>", "Docker container name")
116
+ .option("--env-file <file>", "Path to an env file for runtime secrets")
116
117
  .option("-d, --detach", "Run the container in detached mode")
117
118
  .action(async (inputPath, options) => {
118
119
  const result = await handlers.runProject(inputPath, {
119
120
  authProfile: options.authProfile,
120
121
  containerName: options.name,
121
122
  detach: options.detach,
123
+ envFilePath: options.envFile,
122
124
  imageTag: options.tag,
123
125
  outputDirectory: options.out
124
126
  });
@@ -232,7 +234,7 @@ export const runCli = async (argv, optionsOrStreams, handlerOverrides = {}) => {
232
234
  .command("sync")
233
235
  .argument("[path]", "Project directory or Spawnfile path", process.cwd())
234
236
  .option("-p, --profile <name>", "Auth profile name", "default")
235
- .option("--env-file <file>", "Path to an env file with model API keys")
237
+ .option("--env-file <file>", "Path to an env file with model keys and runtime secrets")
236
238
  .option("--claude-from <directory>", "Source Claude Code config directory")
237
239
  .option("--codex-from <directory>", "Source Codex config directory")
238
240
  .action(async (inputPath, options) => {
@@ -8,9 +8,9 @@ const createEnvironmentAssignments = (plan) => {
8
8
  if (plan.instancePaths.homePath) {
9
9
  envAssignments.push(`HOME=${shellQuote(plan.instancePaths.homePath)}`);
10
10
  }
11
- if (plan.runtimeName === "tinyclaw" &&
12
- plan.instancePaths.homePath &&
13
- (plan.modelAuthMethods.openai === "api_key" || plan.modelAuthMethods.openai === "codex")) {
11
+ if (plan.instancePaths.homePath && ((plan.runtimeName === "tinyclaw" &&
12
+ (plan.modelAuthMethods.openai === "api_key" || plan.modelAuthMethods.openai === "codex")) ||
13
+ (plan.runtimeName === "picoclaw" && plan.modelAuthMethods.openai === "codex"))) {
14
14
  envAssignments.push(`CODEX_HOME=${shellQuote(path.posix.join(plan.instancePaths.homePath, ".codex"))}`);
15
15
  }
16
16
  if (plan.meta.homeEnv && plan.instancePaths.homePath) {
@@ -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);
@@ -7,39 +7,64 @@ import { listExecutionModelSecretNames, resolveExecutionModelAuthMethods } from
7
7
  const resolveAuthRequirements = async (inputPath) => {
8
8
  const plan = await buildCompilePlan(inputPath);
9
9
  const methods = new Set();
10
- const envNames = new Set();
10
+ const optionalEnvNames = new Set();
11
+ const requiredEnvNames = new Set();
12
+ const addProjectSecret = (secret) => {
13
+ if (secret.required) {
14
+ requiredEnvNames.add(secret.name);
15
+ optionalEnvNames.delete(secret.name);
16
+ }
17
+ else if (!requiredEnvNames.has(secret.name)) {
18
+ optionalEnvNames.add(secret.name);
19
+ }
20
+ };
11
21
  for (const node of plan.nodes) {
12
22
  if (node.value.kind !== "agent") {
23
+ for (const secret of node.value.shared.secrets) {
24
+ addProjectSecret(secret);
25
+ }
13
26
  continue;
14
27
  }
28
+ for (const secret of node.value.secrets) {
29
+ addProjectSecret(secret);
30
+ }
15
31
  for (const method of Object.values(resolveExecutionModelAuthMethods(node.value.execution))) {
16
32
  methods.add(method);
17
33
  }
18
34
  for (const envName of listExecutionModelSecretNames(node.value.execution)) {
19
- envNames.add(envName);
35
+ requiredEnvNames.add(envName);
36
+ optionalEnvNames.delete(envName);
20
37
  }
21
38
  for (const envName of listAgentSurfaceSecretNames(node.value.surfaces)) {
22
- envNames.add(envName);
39
+ requiredEnvNames.add(envName);
40
+ optionalEnvNames.delete(envName);
23
41
  }
24
42
  }
25
- return { envNames, methods };
43
+ return { methods, optionalEnvNames, requiredEnvNames };
44
+ };
45
+ const readEnvFile = async (envFilePath) => envFilePath ? parseEnvFile(await readUtf8File(envFilePath)) : {};
46
+ const resolveEnvValue = (envName, fileEnv) => {
47
+ const processValue = process.env[envName];
48
+ if (typeof processValue === "string" && processValue.length > 0) {
49
+ return processValue;
50
+ }
51
+ const fileValue = fileEnv[envName];
52
+ if (typeof fileValue === "string" && fileValue.length > 0) {
53
+ return fileValue;
54
+ }
55
+ return null;
26
56
  };
27
- const resolveRequiredEnv = async (envNames, envFilePath) => {
28
- if (envNames.size === 0) {
57
+ const resolveRequiredEnv = async (requiredEnvNames, optionalEnvNames, envFilePath) => {
58
+ if (requiredEnvNames.size === 0 && optionalEnvNames.size === 0) {
29
59
  return {};
30
60
  }
31
- const fileEnv = envFilePath ? parseEnvFile(await readUtf8File(envFilePath)) : {};
61
+ const fileEnv = await readEnvFile(envFilePath);
32
62
  const resolvedEnv = {};
33
63
  const missingEnv = [];
34
- for (const envName of [...envNames].sort()) {
35
- const processValue = process.env[envName];
36
- if (typeof processValue === "string" && processValue.length > 0) {
37
- resolvedEnv[envName] = processValue;
38
- continue;
39
- }
40
- const fileValue = fileEnv[envName];
41
- if (typeof fileValue === "string" && fileValue.length > 0) {
42
- resolvedEnv[envName] = fileValue;
64
+ for (const envName of [...requiredEnvNames].sort()) {
65
+ const value = resolveEnvValue(envName, fileEnv);
66
+ if (value !== null) {
67
+ resolvedEnv[envName] = value;
43
68
  continue;
44
69
  }
45
70
  missingEnv.push(envName);
@@ -47,10 +72,19 @@ const resolveRequiredEnv = async (envNames, envFilePath) => {
47
72
  if (missingEnv.length > 0) {
48
73
  throw new SpawnfileError("validation_error", `Missing required auth env: ${missingEnv.join(", ")}`);
49
74
  }
75
+ for (const envName of [...optionalEnvNames].sort()) {
76
+ if (requiredEnvNames.has(envName)) {
77
+ continue;
78
+ }
79
+ const value = resolveEnvValue(envName, fileEnv);
80
+ if (value !== null) {
81
+ resolvedEnv[envName] = value;
82
+ }
83
+ }
50
84
  return resolvedEnv;
51
85
  };
52
86
  export const syncProjectAuth = async (inputPath, options) => {
53
- const { envNames, methods } = await resolveAuthRequirements(inputPath);
87
+ const { methods, optionalEnvNames, requiredEnvNames } = await resolveAuthRequirements(inputPath);
54
88
  await ensureAuthProfile(options.profileName);
55
89
  if (methods.has("codex")) {
56
90
  await importCodexAuth(options.profileName, options.codexDirectory);
@@ -58,8 +92,8 @@ export const syncProjectAuth = async (inputPath, options) => {
58
92
  if (methods.has("claude-code")) {
59
93
  await importClaudeCodeAuth(options.profileName, options.claudeCodeDirectory);
60
94
  }
61
- if (envNames.size > 0) {
62
- await setAuthProfileEnv(options.profileName, await resolveRequiredEnv(envNames, options.envFilePath));
95
+ if (requiredEnvNames.size > 0 || optionalEnvNames.size > 0) {
96
+ await setAuthProfileEnv(options.profileName, await resolveRequiredEnv(requiredEnvNames, optionalEnvNames, options.envFilePath));
63
97
  }
64
98
  return requireAuthProfile(options.profileName);
65
99
  };
@@ -47,6 +47,13 @@ const buildModelList = (node) => {
47
47
  model_name: target.name
48
48
  };
49
49
  }
50
+ if (target.provider === "openai" && target.auth.method === "codex") {
51
+ return {
52
+ model: `codex-cli/${target.name}`,
53
+ model_name: target.name,
54
+ workspace: "<workspace-path>"
55
+ };
56
+ }
50
57
  return {
51
58
  ...(target.auth.method === "api_key"
52
59
  ? {
@@ -1,12 +1,12 @@
1
1
  import path from "node:path";
2
- import { loadImportedClaudeCodeCredential, loadImportedCodexCredential } from "../../auth/index.js";
2
+ import { loadImportedClaudeCodeCredential } from "../../auth/index.js";
3
3
  import { copyDirectory, ensureDirectory, readUtf8File, writeUtf8File } from "../../filesystem/index.js";
4
4
  const resolveRootfsSourcePath = (outputDirectory, containerPath) => path.join(outputDirectory, "container", "rootfs", ...path.posix.relative("/", containerPath).split("/"));
5
5
  const createMountArgs = (hostPath, containerPath) => [
6
6
  "-v",
7
7
  `${hostPath}:${containerPath}`
8
8
  ];
9
- const createMountedHomeDirectory = async (input, patchedConfig, authStore) => {
9
+ const createMountedHomeDirectory = async (input, patchedConfig) => {
10
10
  const sourceHomePath = resolveRootfsSourcePath(input.outputDirectory, input.instance.home_path);
11
11
  const mountedHomePath = path.join("runtime-auth", "picoclaw", input.instance.id, "home");
12
12
  const hostHomePath = path.join(input.tempRoot, mountedHomePath);
@@ -14,26 +14,10 @@ const createMountedHomeDirectory = async (input, patchedConfig, authStore) => {
14
14
  await copyDirectory(sourceHomePath, hostHomePath);
15
15
  const relativeConfigPath = path.posix.relative(input.instance.home_path, input.instance.config_path);
16
16
  const hostConfigPath = path.join(hostHomePath, ...relativeConfigPath.split("/"));
17
- const hostAuthPath = path.join(hostHomePath, "auth.json");
18
17
  await ensureDirectory(path.dirname(hostConfigPath));
19
18
  await writeUtf8File(hostConfigPath, `${JSON.stringify(patchedConfig, null, 2)}\n`);
20
- if (authStore) {
21
- await writeUtf8File(hostAuthPath, `${JSON.stringify(authStore, null, 2)}\n`);
22
- }
23
19
  return hostHomePath;
24
20
  };
25
- const createPicoClawCredential = (provider, credential) => ({
26
- access_token: credential.access,
27
- ...("type" in credential && credential.type === "oauth" && credential.refresh
28
- ? { refresh_token: credential.refresh }
29
- : !("type" in credential)
30
- ? { refresh_token: credential.refresh }
31
- : {}),
32
- ...("accountId" in credential && credential.accountId ? { account_id: credential.accountId } : {}),
33
- auth_method: "oauth",
34
- expires_at: new Date(credential.expires).toISOString(),
35
- provider
36
- });
37
21
  const normalizeClaudeCliModelName = (modelName) => modelName.replaceAll(".", "-");
38
22
  const patchPicoClawConfig = (config, options) => {
39
23
  const agents = config.agents ?? {};
@@ -55,21 +39,11 @@ const patchPicoClawConfig = (config, options) => {
55
39
  record.model = `claude-cli/${normalizeClaudeCliModelName(modelName)}`;
56
40
  }
57
41
  }
58
- if (options.useCodex && typeof model === "string" && model.startsWith("openai/")) {
59
- delete record.api_key;
60
- record.auth_method = "oauth";
61
- }
62
42
  return record;
63
43
  });
64
44
  if (options.useClaudeCode) {
65
45
  delete nextProviders.anthropic;
66
46
  }
67
- if (options.useCodex) {
68
- nextProviders.openai = {
69
- ...(nextProviders.openai ?? {}),
70
- auth_method: "oauth"
71
- };
72
- }
73
47
  return {
74
48
  ...config,
75
49
  agents: {
@@ -87,29 +61,19 @@ export const preparePicoClawRuntimeAuth = async (input) => {
87
61
  const claudeCode = input.authProfile.imports["claude-code"]
88
62
  ? await loadImportedClaudeCodeCredential(input.authProfile.imports["claude-code"].path)
89
63
  : null;
90
- const codex = input.authProfile.imports.codex
91
- ? await loadImportedCodexCredential(input.authProfile.imports.codex.path)
92
- : null;
93
64
  const useClaudeCode = input.instance.model_auth_methods.anthropic === "claude-code" && claudeCode;
94
- const useCodex = input.instance.model_auth_methods.openai === "codex" && codex;
95
- if (!useClaudeCode && !useCodex) {
65
+ if (!useClaudeCode) {
96
66
  return { coveredModelSecrets: [], mountArgs: [] };
97
67
  }
98
- const credentials = {};
99
68
  const coveredModelSecrets = [];
100
- if (useCodex) {
101
- credentials.openai = createPicoClawCredential("openai", codex);
102
- coveredModelSecrets.push("OPENAI_API_KEY");
103
- }
104
69
  if (useClaudeCode) {
105
70
  coveredModelSecrets.push("ANTHROPIC_API_KEY");
106
71
  }
107
72
  const sourceConfig = JSON.parse(await readUtf8File(resolveRootfsSourcePath(input.outputDirectory, input.instance.config_path)));
108
73
  const patchedConfig = patchPicoClawConfig(sourceConfig, {
109
- useClaudeCode: Boolean(useClaudeCode),
110
- useCodex: Boolean(useCodex)
74
+ useClaudeCode: Boolean(useClaudeCode)
111
75
  });
112
- const mountedHomePath = await createMountedHomeDirectory(input, patchedConfig, Object.keys(credentials).length > 0 ? { credentials } : null);
76
+ const mountedHomePath = await createMountedHomeDirectory(input, patchedConfig);
113
77
  return {
114
78
  coveredModelSecrets,
115
79
  mountArgs: createMountArgs(mountedHomePath, input.instance.home_path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spawnfile",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Canonical source compiler for autonomous agents and teams.",
5
5
  "license": "MIT",
6
6
  "type": "module",