@polycore/runner 0.1.1 → 0.3.0
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 +12 -4
- package/package.json +2 -2
- package/src/cli.ts +26 -10
- package/src/config.ts +200 -38
- package/src/connect.ts +3 -0
- package/src/connector.ts +15 -2
- package/src/index.ts +112 -5
- package/src/workload/capability.ts +118 -0
- package/src/workload/container-backend.ts +257 -0
- package/src/workload/digest.ts +24 -0
- package/src/workload/limits.ts +43 -0
- package/src/workload/types.ts +110 -0
package/README.md
CHANGED
|
@@ -93,7 +93,6 @@ environment can carry credentials for several backends:
|
|
|
93
93
|
"firestore": { "projectId": "alpha-prod" }
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
|
-
"actionsDir": "/absolute/path/to/alpha/polycore-actions",
|
|
97
96
|
"contextFile": "/absolute/path/to/alpha/schema.md"
|
|
98
97
|
},
|
|
99
98
|
"beta": {
|
|
@@ -113,10 +112,16 @@ environment can carry credentials for several backends:
|
|
|
113
112
|
}
|
|
114
113
|
```
|
|
115
114
|
|
|
115
|
+
Actions are discovered by **convention**, not a config field: put a project's
|
|
116
|
+
actions in an `actions/<slug>/` directory beside the config file, or — when the
|
|
117
|
+
runner serves a single project — directly in `actions/`. They are loaded
|
|
118
|
+
alongside the built-in connectors. (There is no `actionsDir` field.)
|
|
119
|
+
|
|
116
120
|
Per-project extras:
|
|
117
121
|
|
|
118
|
-
- `
|
|
119
|
-
|
|
122
|
+
- `defaultEnvironment`: the environment an unqualified ask targets. It is the
|
|
123
|
+
runner's local-CLI default AND is advertised to the control plane as the
|
|
124
|
+
front-door default. Must be one of the project's `environments`.
|
|
120
125
|
- `secrets`: per-project action secret values (falls back to the runner-level
|
|
121
126
|
`secrets` map, then `process.env`). These never reach the control plane.
|
|
122
127
|
- `context` / `contextFile`: an agent-grounding document (e.g. the product's
|
|
@@ -246,8 +251,11 @@ gcloud run deploy polycore-runner \
|
|
|
246
251
|
|
|
247
252
|
When `POLYCORE_PROJECTS` is set and no `--config` file is given, the runner
|
|
248
253
|
builds its config from these vars; a missing/invalid one fails loud at startup.
|
|
249
|
-
Per-project settings that reference local files (`
|
|
254
|
+
Per-project settings that reference local files (`contextFile`,
|
|
250
255
|
`serviceAccountKeyPath`) point at paths baked into or mounted on the container.
|
|
256
|
+
Actions can only be discovered via the `actions/` convention beside a `--config`
|
|
257
|
+
file, so an env-only config serves just the built-in connectors — prefer a
|
|
258
|
+
committed `polycore.json` (loaded with `--config` / `POLYCORE_RUNNER_CONFIG`).
|
|
251
259
|
|
|
252
260
|
## Tests
|
|
253
261
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycore/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Polycore runner: executes governed, read-only-constrained connectors against a customer's datastores. Runs in the customer's own infrastructure; holds scoped credentials and connects outbound to the control plane.",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"tsx": "4.21.0",
|
|
31
31
|
"ws": "8.18.3",
|
|
32
32
|
"zod": "4.4.3",
|
|
33
|
-
"@polycore/protocol": "^0.
|
|
33
|
+
"@polycore/protocol": "^0.3.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "25.6.2",
|
package/src/cli.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
buildRunnerConfigFromEnv,
|
|
6
7
|
hasEnvConfig,
|
|
7
8
|
loadRunnerConfig,
|
|
9
|
+
overlayEnvSecrets,
|
|
8
10
|
resolveEnvironment,
|
|
9
11
|
resolveProject,
|
|
10
12
|
RUNNER_CONFIG_ENV,
|
|
@@ -19,10 +21,10 @@ import { buildProjectRegistries } from "./index.js";
|
|
|
19
21
|
* before the control-plane connection exists. Logs go to stderr; the JSON
|
|
20
22
|
* result goes to stdout, so output is pipeable.
|
|
21
23
|
*
|
|
22
|
-
* pnpm --filter @polycore/runner runner list --config ./
|
|
24
|
+
* pnpm --filter @polycore/runner runner list --config ./polycore.json
|
|
23
25
|
* pnpm --filter @polycore/runner runner firestore.count \
|
|
24
26
|
* --project alpha --env dev --args '{"collection":"users"}' \
|
|
25
|
-
* --config ./
|
|
27
|
+
* --config ./polycore.json
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
interface ParsedArgs {
|
|
@@ -82,13 +84,21 @@ function printUsage(): void {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
/**
|
|
85
|
-
* Pick the config source
|
|
86
|
-
*
|
|
87
|
-
* (`
|
|
87
|
+
* Pick the config source, in priority order:
|
|
88
|
+
*
|
|
89
|
+
* 1. An explicit config **file** (flag or `POLYCORE_RUNNER_CONFIG`). This is
|
|
90
|
+
* the committed, code-as-source-of-truth path: the file declares the whole
|
|
91
|
+
* structural surface (projects, environments, actions, context) with
|
|
92
|
+
* portable relative paths, and any deploy-time secrets it omits
|
|
93
|
+
* (`controlPlane` creds, runner-level `secrets`) are overlaid from env, so
|
|
94
|
+
* the production container bakes in the file and injects only secrets.
|
|
95
|
+
* 2. Else a fully env-driven config (`POLYCORE_PROJECTS` present), the legacy
|
|
96
|
+
* path where the whole structure arrives as env JSON.
|
|
88
97
|
*/
|
|
89
|
-
function resolveConfig(
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
function resolveConfig(configPath: string | undefined): RunnerConfig {
|
|
99
|
+
if (configPath !== undefined) {
|
|
100
|
+
return overlayEnvSecrets(loadRunnerConfig(configPath), process.env);
|
|
101
|
+
}
|
|
92
102
|
if (hasEnvConfig(process.env)) return buildRunnerConfigFromEnv(process.env);
|
|
93
103
|
throw new Error(
|
|
94
104
|
`No runner config. Pass --config <path>, set ${RUNNER_CONFIG_ENV}, ` +
|
|
@@ -133,8 +143,14 @@ async function main(): Promise<void> {
|
|
|
133
143
|
maybeStartHealthServer((message) => process.stderr.write(`${message}\n`));
|
|
134
144
|
}
|
|
135
145
|
|
|
136
|
-
const
|
|
137
|
-
const
|
|
146
|
+
const configPath = flags.get("config") ?? process.env[RUNNER_CONFIG_ENV];
|
|
147
|
+
const config = resolveConfig(configPath);
|
|
148
|
+
// Actions are discovered from the `actions/` convention beside the config
|
|
149
|
+
// file; an env-driven config (no file) serves only the built-in connectors.
|
|
150
|
+
const registries = await buildProjectRegistries(
|
|
151
|
+
config,
|
|
152
|
+
configPath === undefined ? undefined : dirname(configPath),
|
|
153
|
+
);
|
|
138
154
|
|
|
139
155
|
if (command === "list") {
|
|
140
156
|
for (const [slug, registry] of registries) {
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
2
3
|
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
|
|
@@ -51,15 +52,14 @@ export const ProjectConfigSchema = z
|
|
|
51
52
|
.refine((envs) => Object.keys(envs).length > 0, {
|
|
52
53
|
message: "At least one environment must be configured.",
|
|
53
54
|
}),
|
|
54
|
-
/** Local-CLI default for `runner <capability>` runs. The control plane
|
|
55
|
-
* keeps its own per-project default for front-door asks. */
|
|
56
|
-
defaultEnvironment: z.string().min(1).optional(),
|
|
57
55
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* The environment an unqualified ask targets: the runner's local-CLI
|
|
57
|
+
* default AND the value advertised to the control plane, which uses it as
|
|
58
|
+
* the front-door default when a caller names no environment. One source of
|
|
59
|
+
* truth for "the default env of this project"; must be one of the
|
|
60
|
+
* `environments` keys to take effect.
|
|
61
61
|
*/
|
|
62
|
-
|
|
62
|
+
defaultEnvironment: z.string().min(1).optional(),
|
|
63
63
|
/**
|
|
64
64
|
* Secret values for this project's actions, keyed by the names actions
|
|
65
65
|
* declare in `secrets`. Missing keys fall back to the runner-level
|
|
@@ -82,6 +82,55 @@ export const ProjectConfigSchema = z
|
|
|
82
82
|
|
|
83
83
|
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Resource caps a delegated workload runs under. Each field is an upper bound
|
|
87
|
+
* the runner enforces at the container boundary regardless of what the
|
|
88
|
+
* approved code requests; a workload may ask for less, never more.
|
|
89
|
+
*/
|
|
90
|
+
export const SandboxLimitsSchema = z.object({
|
|
91
|
+
/** CPU cores (fractional allowed), e.g. `1` or `0.5`. */
|
|
92
|
+
cpus: z.number().positive().optional(),
|
|
93
|
+
/** Memory ceiling in MiB. */
|
|
94
|
+
memoryMb: z.number().int().positive().optional(),
|
|
95
|
+
/** Wall-clock timeout in seconds; the container is killed past it. */
|
|
96
|
+
timeoutSeconds: z.number().int().positive().optional(),
|
|
97
|
+
/** Max process count inside the sandbox (fork-bomb guard). */
|
|
98
|
+
pids: z.number().int().positive().optional(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export type SandboxLimits = z.infer<typeof SandboxLimitsSchema>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enables the delegated-workload executor (`workload.execute`) on this runner.
|
|
105
|
+
* When present, the runner advertises a single generic capability that runs
|
|
106
|
+
* **human-approved** arbitrary code inside a disposable, OS-isolated container
|
|
107
|
+
* on the runner's own host. The container holds only the credentials the
|
|
108
|
+
* approved workload requested; it never sees the runner's other secrets.
|
|
109
|
+
*
|
|
110
|
+
* Absent means the runner serves only connectors + actions, exactly as before.
|
|
111
|
+
*/
|
|
112
|
+
export const SandboxConfigSchema = z.object({
|
|
113
|
+
/**
|
|
114
|
+
* Base container image the workload runs in. Must contain a Node runtime
|
|
115
|
+
* plus `tsx` on `PATH` (to run `.ts` entrypoints) and a package manager
|
|
116
|
+
* (`npm`) for workloads that install dependencies.
|
|
117
|
+
*/
|
|
118
|
+
image: z.string().min(1),
|
|
119
|
+
/**
|
|
120
|
+
* OCI runtime that isolates each workload. Defaults to `runsc` (gVisor):
|
|
121
|
+
* syscall-level isolation that runs on any Linux VM with no nested
|
|
122
|
+
* virtualization. Set to `runc` only where gVisor is unavailable (weaker,
|
|
123
|
+
* shared-kernel isolation).
|
|
124
|
+
*/
|
|
125
|
+
runtime: z.string().min(1).optional(),
|
|
126
|
+
/** Container CLI to invoke (default `docker`). */
|
|
127
|
+
cli: z.string().min(1).optional(),
|
|
128
|
+
/** Default/maximum resource limits applied to every workload. */
|
|
129
|
+
limits: SandboxLimitsSchema.optional(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export type SandboxConfig = z.infer<typeof SandboxConfigSchema>;
|
|
133
|
+
|
|
85
134
|
/**
|
|
86
135
|
* How the runner reaches the control plane. Provisioned at enrollment; the
|
|
87
136
|
* `signingSecret` is shared out-of-band (never sent on the wire) so the
|
|
@@ -119,6 +168,11 @@ export const RunnerConfigSchema = z.object({
|
|
|
119
168
|
* `process.env`. These never reach the control plane.
|
|
120
169
|
*/
|
|
121
170
|
secrets: z.record(z.string(), z.string()).optional(),
|
|
171
|
+
/**
|
|
172
|
+
* Opt-in delegated-workload execution (`workload.execute`). Absent leaves
|
|
173
|
+
* the runner serving only connectors + actions. See {@link SandboxConfigSchema}.
|
|
174
|
+
*/
|
|
175
|
+
sandbox: SandboxConfigSchema.optional(),
|
|
122
176
|
});
|
|
123
177
|
|
|
124
178
|
export type RunnerConfig = z.infer<typeof RunnerConfigSchema>;
|
|
@@ -132,9 +186,78 @@ export function parseRunnerConfig(json: string): RunnerConfig {
|
|
|
132
186
|
return RunnerConfigSchema.parse(data);
|
|
133
187
|
}
|
|
134
188
|
|
|
135
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Read and validate a runner config from a JSON file path, then absolute-
|
|
191
|
+
* resolve its file-referencing fields (`contextFile`, each environment's
|
|
192
|
+
* `serviceAccountKeyPath`) against the config file's own directory. This is
|
|
193
|
+
* what lets a committed config use portable, repo-relative paths
|
|
194
|
+
* (`"./context/app.md"`): the customer's config file is the source of truth and
|
|
195
|
+
* the runner does the path bookkeeping. Absolute paths (e.g. a mounted secret
|
|
196
|
+
* at `/secrets/read.json`) pass through unchanged. Actions are discovered by
|
|
197
|
+
* convention from an `actions/` directory beside the config file (see
|
|
198
|
+
* `buildProjectRegistries`), not via a config field.
|
|
199
|
+
*/
|
|
136
200
|
export function loadRunnerConfig(path: string): RunnerConfig {
|
|
137
|
-
|
|
201
|
+
const config = parseRunnerConfig(readFileSync(path, "utf8"));
|
|
202
|
+
return resolveConfigPaths(config, dirname(path));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Absolute-resolve one path field against `baseDir` when it is relative. */
|
|
206
|
+
function resolveConfigPath(value: string, baseDir: string): string {
|
|
207
|
+
return isAbsolute(value) ? value : resolve(baseDir, value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Absolute-resolve an environment's `serviceAccountKeyPath` against `baseDir`. */
|
|
211
|
+
function resolveEnvironmentPaths(
|
|
212
|
+
env: EnvironmentConfig,
|
|
213
|
+
baseDir: string,
|
|
214
|
+
): EnvironmentConfig {
|
|
215
|
+
const firestore = env.firestore;
|
|
216
|
+
if (firestore?.serviceAccountKeyPath === undefined) return env;
|
|
217
|
+
return {
|
|
218
|
+
...env,
|
|
219
|
+
firestore: {
|
|
220
|
+
...firestore,
|
|
221
|
+
serviceAccountKeyPath: resolveConfigPath(
|
|
222
|
+
firestore.serviceAccountKeyPath,
|
|
223
|
+
baseDir,
|
|
224
|
+
),
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Absolute-resolve one project's file-referencing fields against `baseDir`. */
|
|
230
|
+
function resolveProjectPaths(
|
|
231
|
+
project: ProjectConfig,
|
|
232
|
+
baseDir: string,
|
|
233
|
+
): ProjectConfig {
|
|
234
|
+
const environments = Object.fromEntries(
|
|
235
|
+
Object.entries(project.environments).map(([name, env]) => [
|
|
236
|
+
name,
|
|
237
|
+
resolveEnvironmentPaths(env, baseDir),
|
|
238
|
+
]),
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
...project,
|
|
242
|
+
environments,
|
|
243
|
+
...(project.contextFile === undefined
|
|
244
|
+
? {}
|
|
245
|
+
: { contextFile: resolveConfigPath(project.contextFile, baseDir) }),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Absolute-resolve every project's file-referencing fields against `baseDir`. */
|
|
250
|
+
function resolveConfigPaths(
|
|
251
|
+
config: RunnerConfig,
|
|
252
|
+
baseDir: string,
|
|
253
|
+
): RunnerConfig {
|
|
254
|
+
const projects = Object.fromEntries(
|
|
255
|
+
Object.entries(config.projects).map(([slug, project]) => [
|
|
256
|
+
slug,
|
|
257
|
+
resolveProjectPaths(project, baseDir),
|
|
258
|
+
]),
|
|
259
|
+
);
|
|
260
|
+
return { ...config, projects };
|
|
138
261
|
}
|
|
139
262
|
|
|
140
263
|
/** Env var carrying the projects map (JSON) for env-driven config. */
|
|
@@ -169,41 +292,80 @@ export function buildRunnerConfigFromEnv(
|
|
|
169
292
|
`${RUNNER_PROJECTS_ENV} is required to build a config from the environment.`,
|
|
170
293
|
);
|
|
171
294
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// can load actions in a container without enrollment creds), while a *partial*
|
|
175
|
-
// block stays included so the schema pinpoints the missing field.
|
|
176
|
-
const cpUrl = source["POLYCORE_CONTROL_PLANE_URL"];
|
|
177
|
-
const cpRunnerId = source["POLYCORE_RUNNER_ID"];
|
|
178
|
-
const cpJoinToken = source["POLYCORE_JOIN_TOKEN"];
|
|
179
|
-
const cpSigningSecret = source["POLYCORE_SIGNING_SECRET"];
|
|
180
|
-
const hasAnyControlPlane =
|
|
181
|
-
cpUrl !== undefined ||
|
|
182
|
-
cpRunnerId !== undefined ||
|
|
183
|
-
cpJoinToken !== undefined ||
|
|
184
|
-
cpSigningSecret !== undefined;
|
|
185
|
-
|
|
295
|
+
const controlPlane = controlPlaneFromEnv(source);
|
|
296
|
+
const secrets = secretsFromEnv(source);
|
|
186
297
|
const candidate = {
|
|
187
298
|
projects: parseJsonEnv(projectsRaw, RUNNER_PROJECTS_ENV),
|
|
188
|
-
...(
|
|
189
|
-
|
|
190
|
-
controlPlane: {
|
|
191
|
-
url: cpUrl,
|
|
192
|
-
runnerId: cpRunnerId,
|
|
193
|
-
joinToken: cpJoinToken,
|
|
194
|
-
signingSecret: cpSigningSecret,
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
: {}),
|
|
198
|
-
...(source["POLYCORE_SECRETS"] === undefined
|
|
199
|
-
? {}
|
|
200
|
-
: {
|
|
201
|
-
secrets: parseJsonEnv(source["POLYCORE_SECRETS"], "POLYCORE_SECRETS"),
|
|
202
|
-
}),
|
|
299
|
+
...(controlPlane === undefined ? {} : { controlPlane }),
|
|
300
|
+
...(secrets === undefined ? {} : { secrets }),
|
|
203
301
|
};
|
|
204
302
|
return RunnerConfigSchema.parse(candidate);
|
|
205
303
|
}
|
|
206
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Overlay deploy-time secrets from the environment onto a **file-loaded**
|
|
307
|
+
* config: the committed config holds only the code-defined structure
|
|
308
|
+
* (projects, environments, actions, context), while the outbound link's creds
|
|
309
|
+
* (`controlPlane`) and runner-level action `secrets` arrive as env vars from a
|
|
310
|
+
* secret manager and are never written to the repo. Env only *fills* what the
|
|
311
|
+
* file left unset; a value already present in the file always wins.
|
|
312
|
+
*
|
|
313
|
+
* This is the counterpart to {@link loadRunnerConfig} for the production path:
|
|
314
|
+
* bake the structural config into the image, inject secrets as env at runtime.
|
|
315
|
+
*/
|
|
316
|
+
export function overlayEnvSecrets(
|
|
317
|
+
config: RunnerConfig,
|
|
318
|
+
source: NodeJS.ProcessEnv,
|
|
319
|
+
): RunnerConfig {
|
|
320
|
+
const controlPlane = config.controlPlane ?? controlPlaneFromEnv(source);
|
|
321
|
+
const secrets = config.secrets ?? secretsFromEnv(source);
|
|
322
|
+
return {
|
|
323
|
+
...config,
|
|
324
|
+
...(controlPlane === undefined ? {} : { controlPlane }),
|
|
325
|
+
...(secrets === undefined ? {} : { secrets }),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build the control-plane block from env vars, or `undefined` when none is set
|
|
331
|
+
* (so `runner list` can load actions in a container without enrollment creds).
|
|
332
|
+
* A *partial* block is passed through to the schema so it pinpoints exactly
|
|
333
|
+
* which field is missing rather than silently dropping the block.
|
|
334
|
+
*/
|
|
335
|
+
function controlPlaneFromEnv(
|
|
336
|
+
source: NodeJS.ProcessEnv,
|
|
337
|
+
): ControlPlaneConfig | undefined {
|
|
338
|
+
const url = source["POLYCORE_CONTROL_PLANE_URL"];
|
|
339
|
+
const runnerId = source["POLYCORE_RUNNER_ID"];
|
|
340
|
+
const joinToken = source["POLYCORE_JOIN_TOKEN"];
|
|
341
|
+
const signingSecret = source["POLYCORE_SIGNING_SECRET"];
|
|
342
|
+
if (
|
|
343
|
+
url === undefined &&
|
|
344
|
+
runnerId === undefined &&
|
|
345
|
+
joinToken === undefined &&
|
|
346
|
+
signingSecret === undefined
|
|
347
|
+
) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
return ControlPlaneConfigSchema.parse({
|
|
351
|
+
url,
|
|
352
|
+
runnerId,
|
|
353
|
+
joinToken,
|
|
354
|
+
signingSecret,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const runnerSecretsSchema = z.record(z.string(), z.string());
|
|
359
|
+
|
|
360
|
+
/** Runner-level action secrets from `POLYCORE_SECRETS`, or `undefined`. */
|
|
361
|
+
function secretsFromEnv(
|
|
362
|
+
source: NodeJS.ProcessEnv,
|
|
363
|
+
): Record<string, string> | undefined {
|
|
364
|
+
const raw = source["POLYCORE_SECRETS"];
|
|
365
|
+
if (raw === undefined) return undefined;
|
|
366
|
+
return runnerSecretsSchema.parse(parseJsonEnv(raw, "POLYCORE_SECRETS"));
|
|
367
|
+
}
|
|
368
|
+
|
|
207
369
|
function parseJsonEnv(raw: string, name: string): unknown {
|
|
208
370
|
try {
|
|
209
371
|
return JSON.parse(raw) as unknown;
|
package/src/connect.ts
CHANGED
|
@@ -47,6 +47,9 @@ export function describeProjects(
|
|
|
47
47
|
slug,
|
|
48
48
|
environments: Object.keys(project.environments),
|
|
49
49
|
connectors: describeConnectors(registry),
|
|
50
|
+
...(project.defaultEnvironment === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: { defaultEnvironment: project.defaultEnvironment }),
|
|
50
53
|
...(context === undefined ? {} : { context }),
|
|
51
54
|
};
|
|
52
55
|
});
|
package/src/connector.ts
CHANGED
|
@@ -13,8 +13,11 @@ export type ConnectorEffect = "read";
|
|
|
13
13
|
/** Hazard class of any capability (connector or action). */
|
|
14
14
|
export type CapabilityEffect = "read" | "mutate";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Whether a capability is a built-in connector, a customer-authored action,
|
|
18
|
+
* or the generic delegated-workload executor (`workload.execute`).
|
|
19
|
+
*/
|
|
20
|
+
export type CapabilityKind = "connector" | "action" | "workload";
|
|
18
21
|
|
|
19
22
|
/** Everything a connector needs to execute one request. */
|
|
20
23
|
export interface ConnectorContext {
|
|
@@ -168,6 +171,16 @@ export class ConnectorRegistry {
|
|
|
168
171
|
}
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Register a pre-built capability directly. Used for capabilities that are
|
|
176
|
+
* neither a read-only `Connector` nor a folder-loaded action, e.g. the
|
|
177
|
+
* `workload.execute` executor, whose `effect` is `mutate` and whose `run`
|
|
178
|
+
* bridges into a sandbox backend.
|
|
179
|
+
*/
|
|
180
|
+
public registerCapability(capability: RegisteredCapability): void {
|
|
181
|
+
this.add(capability);
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
/**
|
|
172
185
|
* Register a customer-authored action as a capability. The action's `input`
|
|
173
186
|
* schema becomes the capability params; its declared secrets are resolved
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
1
4
|
import type { LoadedAction } from "./action.js";
|
|
2
5
|
import type { ProjectConfig, RunnerConfig } from "./config.js";
|
|
3
6
|
import {
|
|
@@ -7,6 +10,15 @@ import {
|
|
|
7
10
|
} from "./connector.js";
|
|
8
11
|
import { firestoreConnectors } from "./connectors/firestore.js";
|
|
9
12
|
import { loadActions } from "./load-actions.js";
|
|
13
|
+
import {
|
|
14
|
+
createSandboxBackend,
|
|
15
|
+
createWorkloadCapability,
|
|
16
|
+
} from "./workload/capability.js";
|
|
17
|
+
import { resolveSandboxMaxima } from "./workload/limits.js";
|
|
18
|
+
import type {
|
|
19
|
+
ResolvedSandboxLimits,
|
|
20
|
+
SandboxBackend,
|
|
21
|
+
} from "./workload/types.js";
|
|
10
22
|
|
|
11
23
|
export type {
|
|
12
24
|
Action,
|
|
@@ -23,6 +35,7 @@ export type {
|
|
|
23
35
|
ProjectConfig,
|
|
24
36
|
RunnerConfig,
|
|
25
37
|
} from "./config.js";
|
|
38
|
+
export type { SandboxConfig, SandboxLimits } from "./config.js";
|
|
26
39
|
export {
|
|
27
40
|
buildRunnerConfigFromEnv,
|
|
28
41
|
ControlPlaneConfigSchema,
|
|
@@ -30,6 +43,7 @@ export {
|
|
|
30
43
|
FirestoreEnvironmentSchema,
|
|
31
44
|
hasEnvConfig,
|
|
32
45
|
loadRunnerConfig,
|
|
46
|
+
overlayEnvSecrets,
|
|
33
47
|
parseRunnerConfig,
|
|
34
48
|
ProjectConfigSchema,
|
|
35
49
|
resolveEnvironment,
|
|
@@ -38,6 +52,8 @@ export {
|
|
|
38
52
|
RUNNER_CONFIG_ENV,
|
|
39
53
|
RUNNER_PROJECTS_ENV,
|
|
40
54
|
RunnerConfigSchema,
|
|
55
|
+
SandboxConfigSchema,
|
|
56
|
+
SandboxLimitsSchema,
|
|
41
57
|
} from "./config.js";
|
|
42
58
|
export type { RunnerClientOptions } from "./connect.js";
|
|
43
59
|
export {
|
|
@@ -62,6 +78,38 @@ export {
|
|
|
62
78
|
} from "./connector.js";
|
|
63
79
|
export { firestoreConnectors } from "./connectors/firestore.js";
|
|
64
80
|
export { loadActions } from "./load-actions.js";
|
|
81
|
+
export {
|
|
82
|
+
createSandboxBackend,
|
|
83
|
+
createWorkloadCapability,
|
|
84
|
+
WORKLOAD_CAPABILITY_NAME,
|
|
85
|
+
} from "./workload/capability.js";
|
|
86
|
+
export {
|
|
87
|
+
assertSafeRelativePath,
|
|
88
|
+
buildContainerArgs,
|
|
89
|
+
buildRunScript,
|
|
90
|
+
ContainerSandboxBackend,
|
|
91
|
+
} from "./workload/container-backend.js";
|
|
92
|
+
export { workloadDigest } from "./workload/digest.js";
|
|
93
|
+
export {
|
|
94
|
+
clampWorkloadLimits,
|
|
95
|
+
DEFAULT_SANDBOX_LIMITS,
|
|
96
|
+
resolveSandboxMaxima,
|
|
97
|
+
} from "./workload/limits.js";
|
|
98
|
+
export type {
|
|
99
|
+
ResolvedSandboxLimits,
|
|
100
|
+
SandboxBackend,
|
|
101
|
+
SandboxRunRequest,
|
|
102
|
+
SandboxRunResult,
|
|
103
|
+
WorkloadFile,
|
|
104
|
+
WorkloadLimits,
|
|
105
|
+
WorkloadResult,
|
|
106
|
+
WorkloadSpec,
|
|
107
|
+
} from "./workload/types.js";
|
|
108
|
+
export {
|
|
109
|
+
workloadFileSchema,
|
|
110
|
+
workloadLimitsSchema,
|
|
111
|
+
workloadSpecSchema,
|
|
112
|
+
} from "./workload/types.js";
|
|
65
113
|
// The pinned Zod, re-exported so action authors share the runner's exact
|
|
66
114
|
// instance, schemas must be introspectable by the runner's `z.toJSONSchema`.
|
|
67
115
|
// Authors import `{ defineAction, z }` from "@polycore/runner", never from a
|
|
@@ -81,11 +129,20 @@ export interface CreateRegistryOptions {
|
|
|
81
129
|
* family at least one of the project's environments configures.
|
|
82
130
|
*/
|
|
83
131
|
readonly project?: ProjectConfig;
|
|
132
|
+
/**
|
|
133
|
+
* When given, the generic `workload.execute` capability is registered,
|
|
134
|
+
* running approved arbitrary code in `backend` under `maxima` resource caps.
|
|
135
|
+
*/
|
|
136
|
+
readonly sandbox?: {
|
|
137
|
+
readonly backend: SandboxBackend;
|
|
138
|
+
readonly maxima: ResolvedSandboxLimits;
|
|
139
|
+
};
|
|
84
140
|
}
|
|
85
141
|
|
|
86
142
|
/**
|
|
87
|
-
* Build a registry with the runner's built-in connectors (Lane 1)
|
|
88
|
-
* customer-authored actions (Lane 2/3)
|
|
143
|
+
* Build a registry with the runner's built-in connectors (Lane 1), any
|
|
144
|
+
* customer-authored actions (Lane 2/3), and, when a sandbox is provided, the
|
|
145
|
+
* delegated-workload executor.
|
|
89
146
|
*/
|
|
90
147
|
export function createRegistry(
|
|
91
148
|
options: CreateRegistryOptions = {},
|
|
@@ -99,29 +156,79 @@ export function createRegistry(
|
|
|
99
156
|
for (const loaded of options.actions ?? []) {
|
|
100
157
|
registry.registerAction(loaded, options.secrets ?? {});
|
|
101
158
|
}
|
|
159
|
+
if (options.sandbox) {
|
|
160
|
+
registry.registerCapability(
|
|
161
|
+
createWorkloadCapability(options.sandbox.backend, options.secrets ?? {}, {
|
|
162
|
+
maxima: options.sandbox.maxima,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
102
166
|
return registry;
|
|
103
167
|
}
|
|
104
168
|
|
|
169
|
+
/** The conventional directory customer actions live in, beside the config. */
|
|
170
|
+
export const ACTIONS_DIRNAME = "actions";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a project's actions directory by convention, relative to the config
|
|
174
|
+
* file's directory (`baseDir`): a project's actions live in `actions/<slug>/`,
|
|
175
|
+
* and when the runner serves a single project they may live directly in
|
|
176
|
+
* `actions/`. Returns `undefined` when neither exists (a project with no
|
|
177
|
+
* actions, or an env-driven config with no file directory) — the runner then
|
|
178
|
+
* serves only the built-in connectors. There is no `actionsDir` config field:
|
|
179
|
+
* the layout is a fixed convention, exactly like the desktop `src/actions/`.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveActionsDir(
|
|
182
|
+
baseDir: string,
|
|
183
|
+
slug: string,
|
|
184
|
+
projectCount: number,
|
|
185
|
+
): string | undefined {
|
|
186
|
+
const namespaced = join(baseDir, ACTIONS_DIRNAME, slug);
|
|
187
|
+
if (existsSync(namespaced)) return namespaced;
|
|
188
|
+
const shared = join(baseDir, ACTIONS_DIRNAME);
|
|
189
|
+
if (projectCount === 1 && existsSync(shared)) return shared;
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
105
193
|
/**
|
|
106
194
|
* Build one registry per configured project: built-in connectors filtered
|
|
107
195
|
* by what the project's environments configure, plus the project's own
|
|
108
196
|
* actions with its secrets (project secrets win over runner-level secrets;
|
|
109
197
|
* both fall back to `process.env` at dispatch).
|
|
198
|
+
*
|
|
199
|
+
* `actionsBaseDir` is the config file's directory; actions are discovered from
|
|
200
|
+
* the `actions/` convention under it (see {@link resolveActionsDir}). Omit it
|
|
201
|
+
* for an env-driven config with no file location — the runner then serves only
|
|
202
|
+
* the built-in connectors.
|
|
110
203
|
*/
|
|
111
204
|
export async function buildProjectRegistries(
|
|
112
205
|
config: RunnerConfig,
|
|
206
|
+
actionsBaseDir?: string,
|
|
113
207
|
): Promise<Map<string, ConnectorRegistry>> {
|
|
114
208
|
const registries = new Map<string, ConnectorRegistry>();
|
|
209
|
+
const projectCount = Object.keys(config.projects).length;
|
|
210
|
+
// Sandbox is a runner-level capability: one backend + maxima shared across
|
|
211
|
+
// projects, each registered with its own secrets so a workload can only ask
|
|
212
|
+
// for credentials of the project it targets.
|
|
213
|
+
const sandbox = config.sandbox
|
|
214
|
+
? {
|
|
215
|
+
backend: createSandboxBackend(config.sandbox),
|
|
216
|
+
maxima: resolveSandboxMaxima(config.sandbox.limits),
|
|
217
|
+
}
|
|
218
|
+
: undefined;
|
|
115
219
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
220
|
+
const dir =
|
|
221
|
+
actionsBaseDir === undefined
|
|
222
|
+
? undefined
|
|
223
|
+
: resolveActionsDir(actionsBaseDir, slug, projectCount);
|
|
224
|
+
const actions = dir ? await loadActions(dir) : [];
|
|
119
225
|
registries.set(
|
|
120
226
|
slug,
|
|
121
227
|
createRegistry({
|
|
122
228
|
project,
|
|
123
229
|
actions,
|
|
124
230
|
secrets: { ...config.secrets, ...project.secrets },
|
|
231
|
+
...(sandbox ? { sandbox } : {}),
|
|
125
232
|
}),
|
|
126
233
|
);
|
|
127
234
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { SandboxConfig } from "../config.js";
|
|
2
|
+
import type {
|
|
3
|
+
ConnectorContext,
|
|
4
|
+
RegisteredCapability,
|
|
5
|
+
SecretSource,
|
|
6
|
+
} from "../connector.js";
|
|
7
|
+
import { ContainerSandboxBackend } from "./container-backend.js";
|
|
8
|
+
import { workloadDigest } from "./digest.js";
|
|
9
|
+
import { clampWorkloadLimits } from "./limits.js";
|
|
10
|
+
import type {
|
|
11
|
+
ResolvedSandboxLimits,
|
|
12
|
+
SandboxBackend,
|
|
13
|
+
WorkloadResult,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { workloadSpecSchema } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** The single generic capability name for delegated code execution. */
|
|
18
|
+
export const WORKLOAD_CAPABILITY_NAME = "workload.execute";
|
|
19
|
+
|
|
20
|
+
const WORKLOAD_DESCRIPTION =
|
|
21
|
+
"Run a short program you author, in an isolated sandbox on the runner host, " +
|
|
22
|
+
"when no other connector or action covers the task. Use this for arbitrary " +
|
|
23
|
+
"computation and multi-step work: parsing files, reconciling systems, batch " +
|
|
24
|
+
"operations, calling an API no connector exposes. Provide `files` (source, " +
|
|
25
|
+
"at least the entrypoint; include a package.json to use npm libraries), " +
|
|
26
|
+
"`entrypoint` (the file to run; .ts runs under tsx), `secrets` (keys of the " +
|
|
27
|
+
"credentials the code needs, injected as env vars), and set `egress: true` " +
|
|
28
|
+
"to allow network access or dependency installs. It ALWAYS pauses for human " +
|
|
29
|
+
"review of the exact code before running, so propose the workload directly " +
|
|
30
|
+
"rather than asking the user for permission first. Read its stdout for the result.";
|
|
31
|
+
|
|
32
|
+
/** Build the container backend the runner uses to execute workloads. */
|
|
33
|
+
export function createSandboxBackend(config: SandboxConfig): SandboxBackend {
|
|
34
|
+
return new ContainerSandboxBackend({
|
|
35
|
+
image: config.image,
|
|
36
|
+
...(config.runtime === undefined ? {} : { runtime: config.runtime }),
|
|
37
|
+
...(config.cli === undefined ? {} : { cli: config.cli }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve the requested secret keys to values, failing loud on any missing. */
|
|
42
|
+
function resolveWorkloadSecrets(
|
|
43
|
+
keys: readonly string[],
|
|
44
|
+
source: SecretSource,
|
|
45
|
+
): Record<string, string> {
|
|
46
|
+
const resolved: Record<string, string> = {};
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const value = source[key] ?? process.env[key];
|
|
49
|
+
if (value === undefined || value.length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Missing secret "${key}" requested by the workload. Provide it in the ` +
|
|
52
|
+
`runner config's "secrets" or the environment.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
resolved[key] = value;
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A workload installs dependencies when it ships a package.json. */
|
|
61
|
+
function needsInstall(files: readonly { path: string }[]): boolean {
|
|
62
|
+
return files.some(
|
|
63
|
+
(f) => f.path === "package.json" || f.path.endsWith("/package.json"),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface WorkloadCapabilityOptions {
|
|
68
|
+
/** The runner's resource maxima; per-workload requests are clamped to these. */
|
|
69
|
+
readonly maxima: ResolvedSandboxLimits;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The `workload.execute` capability: validate the spec, resolve exactly the
|
|
74
|
+
* requested credentials, clamp the limits, and run it in the sandbox backend.
|
|
75
|
+
* It is `mutate` unconditionally, so the control plane always routes it through
|
|
76
|
+
* human approval, the trust transition that turns agent-authored code into
|
|
77
|
+
* trusted-for-one-run code. The full spec (including the source) is the
|
|
78
|
+
* approved artifact; its digest is returned for audit.
|
|
79
|
+
*/
|
|
80
|
+
export function createWorkloadCapability(
|
|
81
|
+
backend: SandboxBackend,
|
|
82
|
+
secrets: SecretSource,
|
|
83
|
+
options: WorkloadCapabilityOptions,
|
|
84
|
+
): RegisteredCapability {
|
|
85
|
+
return {
|
|
86
|
+
name: WORKLOAD_CAPABILITY_NAME,
|
|
87
|
+
description: WORKLOAD_DESCRIPTION,
|
|
88
|
+
effect: "mutate",
|
|
89
|
+
kind: "workload",
|
|
90
|
+
params: workloadSpecSchema,
|
|
91
|
+
run: async (rawArgs: unknown, ctx: ConnectorContext) => {
|
|
92
|
+
const spec = workloadSpecSchema.parse(rawArgs);
|
|
93
|
+
const egress = spec.egress ?? false;
|
|
94
|
+
const credentials = resolveWorkloadSecrets(spec.secrets ?? [], secrets);
|
|
95
|
+
const limits = clampWorkloadLimits(options.maxima, spec.limits);
|
|
96
|
+
const install = needsInstall(spec.files);
|
|
97
|
+
const outcome = await backend.run({
|
|
98
|
+
files: spec.files,
|
|
99
|
+
entrypoint: spec.entrypoint,
|
|
100
|
+
credentials,
|
|
101
|
+
egress,
|
|
102
|
+
install,
|
|
103
|
+
limits,
|
|
104
|
+
log: (m) => ctx.log(m),
|
|
105
|
+
});
|
|
106
|
+
const result: WorkloadResult = {
|
|
107
|
+
digest: workloadDigest(spec),
|
|
108
|
+
ok: outcome.exitCode === 0 && !outcome.timedOut,
|
|
109
|
+
exitCode: outcome.exitCode,
|
|
110
|
+
stdout: outcome.stdout,
|
|
111
|
+
stderr: outcome.stderr,
|
|
112
|
+
timedOut: outcome.timedOut,
|
|
113
|
+
durationMs: outcome.durationMs,
|
|
114
|
+
};
|
|
115
|
+
return result;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ResolvedSandboxLimits,
|
|
9
|
+
SandboxBackend,
|
|
10
|
+
SandboxRunRequest,
|
|
11
|
+
SandboxRunResult,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_RUNTIME = "runsc";
|
|
15
|
+
const DEFAULT_CLI = "docker";
|
|
16
|
+
const WORKDIR = "/workload";
|
|
17
|
+
/** Cap captured stream size so a chatty workload can't exhaust runner memory. */
|
|
18
|
+
const MAX_STREAM_BYTES = 1_000_000;
|
|
19
|
+
|
|
20
|
+
export interface ContainerSandboxOptions {
|
|
21
|
+
/** Base image (must carry a Node runtime, `tsx`, and `npm`). */
|
|
22
|
+
readonly image: string;
|
|
23
|
+
/** OCI runtime; defaults to `runsc` (gVisor). */
|
|
24
|
+
readonly runtime?: string;
|
|
25
|
+
/** Container CLI executable; defaults to `docker`. */
|
|
26
|
+
readonly cli?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reject a path that could escape the sandbox workdir. Workload paths are
|
|
31
|
+
* agent-authored, so this is a hard boundary, not a nicety.
|
|
32
|
+
*/
|
|
33
|
+
export function assertSafeRelativePath(path: string): void {
|
|
34
|
+
const segments = path.split("/");
|
|
35
|
+
if (
|
|
36
|
+
path.startsWith("/") ||
|
|
37
|
+
path.includes("\\") ||
|
|
38
|
+
path.includes("\0") ||
|
|
39
|
+
segments.some((s) => s === ".." || s === "")
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Unsafe workload path "${path}": paths must be relative with no "..", ` +
|
|
43
|
+
`absolute, empty, or backslash segments.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Single-quote a token for safe embedding in a `sh -c` script. */
|
|
49
|
+
function shellSingleQuote(value: string): string {
|
|
50
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The in-container command. `.ts`/`.tsx` entrypoints run under `tsx`, others
|
|
55
|
+
* under `node`. When `install` is set, dependencies are installed first (this
|
|
56
|
+
* requires network egress). `exec` replaces the shell so signals reach the
|
|
57
|
+
* process directly for prompt timeout kills.
|
|
58
|
+
*/
|
|
59
|
+
export function buildRunScript(entrypoint: string, install: boolean): string {
|
|
60
|
+
const runner = /\.tsx?$/.test(entrypoint) ? "tsx" : "node";
|
|
61
|
+
const run = `exec ${runner} ${shellSingleQuote(entrypoint)}`;
|
|
62
|
+
if (!install) return run;
|
|
63
|
+
return `npm install --omit=dev --no-audit --no-fund && ${run}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ContainerArgsParams {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly image: string;
|
|
69
|
+
readonly runtime: string;
|
|
70
|
+
readonly hostDir: string;
|
|
71
|
+
readonly egress: boolean;
|
|
72
|
+
readonly limits: ResolvedSandboxLimits;
|
|
73
|
+
readonly secretKeys: readonly string[];
|
|
74
|
+
readonly script: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the container CLI arguments (everything after the `docker`/`podman`
|
|
79
|
+
* executable). Pure and deterministic so the isolation posture is unit-tested:
|
|
80
|
+
* no host network unless egress is granted, read-only root fs, all capabilities
|
|
81
|
+
* dropped, no privilege escalation, hard resource caps, and secrets passed by
|
|
82
|
+
* *key* (`-e KEY`) so their values never appear on the process argv.
|
|
83
|
+
*/
|
|
84
|
+
export function buildContainerArgs(params: ContainerArgsParams): string[] {
|
|
85
|
+
const { limits } = params;
|
|
86
|
+
const args = [
|
|
87
|
+
"run",
|
|
88
|
+
"--rm",
|
|
89
|
+
"--name",
|
|
90
|
+
params.name,
|
|
91
|
+
"--runtime",
|
|
92
|
+
params.runtime,
|
|
93
|
+
"--network",
|
|
94
|
+
params.egress ? "bridge" : "none",
|
|
95
|
+
"--cpus",
|
|
96
|
+
String(limits.cpus),
|
|
97
|
+
"--memory",
|
|
98
|
+
`${limits.memoryMb}m`,
|
|
99
|
+
// Equal memory + swap disables swap, so the memory cap is hard.
|
|
100
|
+
"--memory-swap",
|
|
101
|
+
`${limits.memoryMb}m`,
|
|
102
|
+
"--pids-limit",
|
|
103
|
+
String(limits.pids),
|
|
104
|
+
"--read-only",
|
|
105
|
+
"--tmpfs",
|
|
106
|
+
`/tmp:rw,size=${limits.memoryMb}m`,
|
|
107
|
+
"--cap-drop",
|
|
108
|
+
"ALL",
|
|
109
|
+
"--security-opt",
|
|
110
|
+
"no-new-privileges",
|
|
111
|
+
"-v",
|
|
112
|
+
`${params.hostDir}:${WORKDIR}`,
|
|
113
|
+
"-w",
|
|
114
|
+
WORKDIR,
|
|
115
|
+
];
|
|
116
|
+
for (const key of params.secretKeys) {
|
|
117
|
+
args.push("-e", key);
|
|
118
|
+
}
|
|
119
|
+
args.push(params.image, "sh", "-c", params.script);
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Append to a capped buffer, dropping bytes past the cap. */
|
|
124
|
+
function appendCapped(current: string, chunk: string): string {
|
|
125
|
+
if (current.length >= MAX_STREAM_BYTES) return current;
|
|
126
|
+
return (current + chunk).slice(0, MAX_STREAM_BYTES);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Runs each approved workload in a fresh, OS-isolated container on the runner's
|
|
131
|
+
* host and destroys it afterward. Isolation (gVisor by default), credential
|
|
132
|
+
* injection, and resource limits are enforced at the container boundary; the
|
|
133
|
+
* approved code inside may use any library, SDK, and (when egress is granted)
|
|
134
|
+
* the open internet, exactly as a human reviewer signed off on.
|
|
135
|
+
*/
|
|
136
|
+
export class ContainerSandboxBackend implements SandboxBackend {
|
|
137
|
+
private readonly image: string;
|
|
138
|
+
private readonly runtime: string;
|
|
139
|
+
private readonly cli: string;
|
|
140
|
+
|
|
141
|
+
constructor(options: ContainerSandboxOptions) {
|
|
142
|
+
this.image = options.image;
|
|
143
|
+
this.runtime = options.runtime ?? DEFAULT_RUNTIME;
|
|
144
|
+
this.cli = options.cli ?? DEFAULT_CLI;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public async run(request: SandboxRunRequest): Promise<SandboxRunResult> {
|
|
148
|
+
assertSafeRelativePath(request.entrypoint);
|
|
149
|
+
if (!request.files.some((f) => f.path === request.entrypoint)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Entrypoint "${request.entrypoint}" is not among the workload files.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (request.install && !request.egress) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"Installing dependencies requires network egress; set egress: true.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const dir = await mkdtemp(join(tmpdir(), "polycore-wl-"));
|
|
161
|
+
try {
|
|
162
|
+
await this.writeFiles(dir, request.files);
|
|
163
|
+
return await this.spawnContainer(dir, request);
|
|
164
|
+
} finally {
|
|
165
|
+
await rm(dir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async writeFiles(
|
|
170
|
+
dir: string,
|
|
171
|
+
files: SandboxRunRequest["files"],
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
assertSafeRelativePath(file.path);
|
|
175
|
+
const full = join(dir, file.path);
|
|
176
|
+
await mkdir(dirname(full), { recursive: true });
|
|
177
|
+
await writeFile(full, file.contents, "utf8");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private spawnContainer(
|
|
182
|
+
hostDir: string,
|
|
183
|
+
request: SandboxRunRequest,
|
|
184
|
+
): Promise<SandboxRunResult> {
|
|
185
|
+
const name = `polycore-wl-${randomUUID()}`;
|
|
186
|
+
const secretKeys = Object.keys(request.credentials);
|
|
187
|
+
const script = buildRunScript(request.entrypoint, request.install);
|
|
188
|
+
const args = buildContainerArgs({
|
|
189
|
+
name,
|
|
190
|
+
image: this.image,
|
|
191
|
+
runtime: this.runtime,
|
|
192
|
+
hostDir,
|
|
193
|
+
egress: request.egress,
|
|
194
|
+
limits: request.limits,
|
|
195
|
+
secretKeys,
|
|
196
|
+
script,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
request.log(
|
|
200
|
+
`Running workload in ${this.runtime} sandbox (egress=${request.egress}, ` +
|
|
201
|
+
`timeout=${request.limits.timeoutSeconds}s)…`,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return new Promise<SandboxRunResult>((resolve, reject) => {
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
// Secret values ride the child's env so only `-e KEY` forwards them into
|
|
207
|
+
// the container; they never touch argv. Other host env stays out of the
|
|
208
|
+
// sandbox (it is not forwarded) but is available to the CLI itself.
|
|
209
|
+
const child = spawn(this.cli, args, {
|
|
210
|
+
env: { ...process.env, ...request.credentials },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
let stdout = "";
|
|
214
|
+
let stderr = "";
|
|
215
|
+
let timedOut = false;
|
|
216
|
+
let settled = false;
|
|
217
|
+
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
timedOut = true;
|
|
220
|
+
// Killing the CLI does not stop the container; kill it by name.
|
|
221
|
+
spawn(this.cli, ["kill", name]).on("error", () => undefined);
|
|
222
|
+
child.kill("SIGKILL");
|
|
223
|
+
}, request.limits.timeoutSeconds * 1000);
|
|
224
|
+
|
|
225
|
+
child.stdout.on("data", (d: Buffer) => {
|
|
226
|
+
stdout = appendCapped(stdout, d.toString("utf8"));
|
|
227
|
+
});
|
|
228
|
+
child.stderr.on("data", (d: Buffer) => {
|
|
229
|
+
stderr = appendCapped(stderr, d.toString("utf8"));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
child.on("error", (error) => {
|
|
233
|
+
if (settled) return;
|
|
234
|
+
settled = true;
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
reject(
|
|
237
|
+
new Error(
|
|
238
|
+
`Failed to launch sandbox via "${this.cli}": ${error.message}`,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
child.on("close", (code) => {
|
|
244
|
+
if (settled) return;
|
|
245
|
+
settled = true;
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
resolve({
|
|
248
|
+
exitCode: code ?? -1,
|
|
249
|
+
stdout,
|
|
250
|
+
stderr,
|
|
251
|
+
durationMs: Date.now() - startedAt,
|
|
252
|
+
timedOut,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { WorkloadSpec } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A stable sha256 identity for a workload: the exact thing a human approves and
|
|
7
|
+
* the runner executes. Files are sorted by path and secret keys are sorted, so
|
|
8
|
+
* the digest depends on *what* the workload is and *what authority it asks
|
|
9
|
+
* for*, not on incidental ordering. Any change to code, entrypoint, requested
|
|
10
|
+
* secrets, egress, or limits changes the digest, which is what lets an approval
|
|
11
|
+
* bind to one immutable artifact: re-review is required the moment it differs.
|
|
12
|
+
*/
|
|
13
|
+
export function workloadDigest(spec: WorkloadSpec): string {
|
|
14
|
+
const canonical = {
|
|
15
|
+
files: [...spec.files]
|
|
16
|
+
.map((f) => ({ path: f.path, contents: f.contents }))
|
|
17
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
|
18
|
+
entrypoint: spec.entrypoint,
|
|
19
|
+
secrets: [...(spec.secrets ?? [])].sort(),
|
|
20
|
+
egress: spec.egress ?? false,
|
|
21
|
+
limits: spec.limits ?? {},
|
|
22
|
+
};
|
|
23
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SandboxLimits } from "../config.js";
|
|
2
|
+
import type { ResolvedSandboxLimits, WorkloadLimits } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Conservative defaults when the runner config sets no explicit maxima. */
|
|
5
|
+
export const DEFAULT_SANDBOX_LIMITS: ResolvedSandboxLimits = {
|
|
6
|
+
cpus: 1,
|
|
7
|
+
memoryMb: 512,
|
|
8
|
+
timeoutSeconds: 120,
|
|
9
|
+
pids: 256,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Resolve the runner's configured maxima, filling any unset field with a default. */
|
|
13
|
+
export function resolveSandboxMaxima(
|
|
14
|
+
config: SandboxLimits | undefined,
|
|
15
|
+
): ResolvedSandboxLimits {
|
|
16
|
+
return {
|
|
17
|
+
cpus: config?.cpus ?? DEFAULT_SANDBOX_LIMITS.cpus,
|
|
18
|
+
memoryMb: config?.memoryMb ?? DEFAULT_SANDBOX_LIMITS.memoryMb,
|
|
19
|
+
timeoutSeconds:
|
|
20
|
+
config?.timeoutSeconds ?? DEFAULT_SANDBOX_LIMITS.timeoutSeconds,
|
|
21
|
+
pids: config?.pids ?? DEFAULT_SANDBOX_LIMITS.pids,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clamp a workload's requested limits to the runner's maxima: a workload may
|
|
27
|
+
* ask for less than the configured ceiling, never more. Unset requests default
|
|
28
|
+
* to the maximum.
|
|
29
|
+
*/
|
|
30
|
+
export function clampWorkloadLimits(
|
|
31
|
+
maxima: ResolvedSandboxLimits,
|
|
32
|
+
requested: WorkloadLimits | undefined,
|
|
33
|
+
): ResolvedSandboxLimits {
|
|
34
|
+
return {
|
|
35
|
+
cpus: Math.min(requested?.cpus ?? maxima.cpus, maxima.cpus),
|
|
36
|
+
memoryMb: Math.min(requested?.memoryMb ?? maxima.memoryMb, maxima.memoryMb),
|
|
37
|
+
timeoutSeconds: Math.min(
|
|
38
|
+
requested?.timeoutSeconds ?? maxima.timeoutSeconds,
|
|
39
|
+
maxima.timeoutSeconds,
|
|
40
|
+
),
|
|
41
|
+
pids: maxima.pids,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delegated workloads are the universal escape hatch: when no connector or
|
|
5
|
+
* action covers a task, the agent authors a small program, a human reviews the
|
|
6
|
+
* exact code + dependencies + requested credentials, and on approval the runner
|
|
7
|
+
* executes it in a disposable, OS-isolated sandbox on its own host. This is
|
|
8
|
+
* arbitrary code in the same sense every program is arbitrary: it computes
|
|
9
|
+
* freely, and the credentials it may touch are exactly the ones the approval
|
|
10
|
+
* delegated to it, nothing more.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One source file of a workload bundle, at a workspace-relative path. */
|
|
14
|
+
export const workloadFileSchema = z.object({
|
|
15
|
+
/** POSIX-relative path inside the sandbox workdir, e.g. `main.ts`. */
|
|
16
|
+
path: z.string().min(1),
|
|
17
|
+
/** UTF-8 file contents. */
|
|
18
|
+
contents: z.string(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type WorkloadFile = z.infer<typeof workloadFileSchema>;
|
|
22
|
+
|
|
23
|
+
/** Per-workload resource request; clamped to the runner's configured maxima. */
|
|
24
|
+
export const workloadLimitsSchema = z.object({
|
|
25
|
+
cpus: z.number().positive().optional(),
|
|
26
|
+
memoryMb: z.number().int().positive().optional(),
|
|
27
|
+
timeoutSeconds: z.number().int().positive().optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type WorkloadLimits = z.infer<typeof workloadLimitsSchema>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `workload.execute` arguments the agent supplies. This whole object is
|
|
34
|
+
* what a human approves: the exact files, the entrypoint, which credentials it
|
|
35
|
+
* may use, whether it may reach the network, and its resource envelope.
|
|
36
|
+
*/
|
|
37
|
+
export const workloadSpecSchema = z.object({
|
|
38
|
+
/** Source files of the program (at least the entrypoint). */
|
|
39
|
+
files: z.array(workloadFileSchema).min(1),
|
|
40
|
+
/** Which file to run; must be one of `files`, a safe relative path. */
|
|
41
|
+
entrypoint: z.string().min(1),
|
|
42
|
+
/**
|
|
43
|
+
* Secret keys (from the runner's configured secrets) to inject into the
|
|
44
|
+
* sandbox as environment variables. These are the credentials the approval
|
|
45
|
+
* delegates to the code; omit for a pure-computation workload.
|
|
46
|
+
*/
|
|
47
|
+
secrets: z.array(z.string().min(1)).optional(),
|
|
48
|
+
/**
|
|
49
|
+
* Whether the sandbox may reach the network. Required to install npm
|
|
50
|
+
* dependencies or call any external system. Defaults to false (no egress).
|
|
51
|
+
*/
|
|
52
|
+
egress: z.boolean().optional(),
|
|
53
|
+
/** Optional per-workload resource caps (clamped to the runner's maxima). */
|
|
54
|
+
limits: workloadLimitsSchema.optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export type WorkloadSpec = z.infer<typeof workloadSpecSchema>;
|
|
58
|
+
|
|
59
|
+
/** Resolved, absolute resource caps a single sandbox run executes under. */
|
|
60
|
+
export interface ResolvedSandboxLimits {
|
|
61
|
+
readonly cpus: number;
|
|
62
|
+
readonly memoryMb: number;
|
|
63
|
+
readonly timeoutSeconds: number;
|
|
64
|
+
readonly pids: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything a sandbox backend needs to execute one approved workload. */
|
|
68
|
+
export interface SandboxRunRequest {
|
|
69
|
+
readonly files: readonly WorkloadFile[];
|
|
70
|
+
readonly entrypoint: string;
|
|
71
|
+
/** Resolved secret values to expose as env vars inside the sandbox. */
|
|
72
|
+
readonly credentials: Readonly<Record<string, string>>;
|
|
73
|
+
readonly egress: boolean;
|
|
74
|
+
/** Whether to install dependencies before running (needs `egress`). */
|
|
75
|
+
readonly install: boolean;
|
|
76
|
+
readonly limits: ResolvedSandboxLimits;
|
|
77
|
+
readonly log: (message: string) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The raw outcome of one sandbox run. */
|
|
81
|
+
export interface SandboxRunResult {
|
|
82
|
+
readonly exitCode: number;
|
|
83
|
+
readonly stdout: string;
|
|
84
|
+
readonly stderr: string;
|
|
85
|
+
readonly durationMs: number;
|
|
86
|
+
/** True when the run hit its wall-clock timeout and was killed. */
|
|
87
|
+
readonly timedOut: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Executes an approved workload in isolation. The default implementation runs
|
|
92
|
+
* an OS-isolated container; the interface exists so a microVM (or a fake, in
|
|
93
|
+
* tests) can be swapped in without touching the capability or its callers.
|
|
94
|
+
*/
|
|
95
|
+
export interface SandboxBackend {
|
|
96
|
+
run(request: SandboxRunRequest): Promise<SandboxRunResult>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The `workload.execute` result returned to the control plane and audit. */
|
|
100
|
+
export interface WorkloadResult {
|
|
101
|
+
/** Deterministic identity of the approved artifact (what was run). */
|
|
102
|
+
readonly digest: string;
|
|
103
|
+
/** Convenience flag: exited 0 and did not time out. */
|
|
104
|
+
readonly ok: boolean;
|
|
105
|
+
readonly exitCode: number;
|
|
106
|
+
readonly stdout: string;
|
|
107
|
+
readonly stderr: string;
|
|
108
|
+
readonly timedOut: boolean;
|
|
109
|
+
readonly durationMs: number;
|
|
110
|
+
}
|