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 +2 -0
- package/dist/cli/runCli.js +3 -1
- package/dist/compiler/containerEntrypointRender.js +3 -3
- package/dist/compiler/runProject.d.ts +2 -0
- package/dist/compiler/runProject.js +20 -6
- package/dist/compiler/syncProjectAuth.js +53 -19
- package/dist/runtime/picoclaw/adapter.js +7 -0
- package/dist/runtime/picoclaw/runAuth.js +5 -41
- package/package.json +1 -1
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`.
|
package/dist/cli/runCli.js
CHANGED
|
@@ -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
|
|
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.
|
|
13
|
-
(plan.
|
|
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
|
|
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
|
-
|
|
35
|
+
requiredEnvNames.add(envName);
|
|
36
|
+
optionalEnvNames.delete(envName);
|
|
20
37
|
}
|
|
21
38
|
for (const envName of listAgentSurfaceSecretNames(node.value.surfaces)) {
|
|
22
|
-
|
|
39
|
+
requiredEnvNames.add(envName);
|
|
40
|
+
optionalEnvNames.delete(envName);
|
|
23
41
|
}
|
|
24
42
|
}
|
|
25
|
-
return {
|
|
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 (
|
|
28
|
-
if (
|
|
57
|
+
const resolveRequiredEnv = async (requiredEnvNames, optionalEnvNames, envFilePath) => {
|
|
58
|
+
if (requiredEnvNames.size === 0 && optionalEnvNames.size === 0) {
|
|
29
59
|
return {};
|
|
30
60
|
}
|
|
31
|
-
const fileEnv =
|
|
61
|
+
const fileEnv = await readEnvFile(envFilePath);
|
|
32
62
|
const resolvedEnv = {};
|
|
33
63
|
const missingEnv = [];
|
|
34
|
-
for (const envName of [...
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
resolvedEnv[envName] =
|
|
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 {
|
|
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 (
|
|
62
|
-
await setAuthProfileEnv(options.profileName, await resolveRequiredEnv(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
76
|
+
const mountedHomePath = await createMountedHomeDirectory(input, patchedConfig);
|
|
113
77
|
return {
|
|
114
78
|
coveredModelSecrets,
|
|
115
79
|
mountArgs: createMountArgs(mountedHomePath, input.instance.home_path)
|