@polycore/runner 0.1.0 → 0.2.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 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
- - `actionsDir`: customer-authored actions for that project, loaded alongside
119
- the built-in connectors.
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 (`actionsDir`, `contextFile`,
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.1.0",
3
+ "version": "0.2.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.1.0"
33
+ "@polycore/protocol": "^0.2.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 ./runner.config.json
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 ./runner.config.json
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: an explicit file (flag or `POLYCORE_RUNNER_CONFIG`)
86
- * for local use, else an env-driven config when the container path is set
87
- * (`POLYCORE_PROJECTS` present).
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(configFlag: string | undefined): RunnerConfig {
90
- const configPath = configFlag ?? process.env[RUNNER_CONFIG_ENV];
91
- if (configPath !== undefined) return loadRunnerConfig(configPath);
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 config = resolveConfig(flags.get("config"));
137
- const registries = await buildProjectRegistries(config);
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
- * Absolute path to a directory of customer-authored actions for this
59
- * project (one folder per action, each with an `action.ts`). Loaded and
60
- * served alongside the built-in connectors.
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
- actionsDir: z.string().min(1).optional(),
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
@@ -132,9 +132,78 @@ export function parseRunnerConfig(json: string): RunnerConfig {
132
132
  return RunnerConfigSchema.parse(data);
133
133
  }
134
134
 
135
- /** Read and validate a runner config from a JSON file path. */
135
+ /**
136
+ * Read and validate a runner config from a JSON file path, then absolute-
137
+ * resolve its file-referencing fields (`contextFile`, each environment's
138
+ * `serviceAccountKeyPath`) against the config file's own directory. This is
139
+ * what lets a committed config use portable, repo-relative paths
140
+ * (`"./context/app.md"`): the customer's config file is the source of truth and
141
+ * the runner does the path bookkeeping. Absolute paths (e.g. a mounted secret
142
+ * at `/secrets/read.json`) pass through unchanged. Actions are discovered by
143
+ * convention from an `actions/` directory beside the config file (see
144
+ * `buildProjectRegistries`), not via a config field.
145
+ */
136
146
  export function loadRunnerConfig(path: string): RunnerConfig {
137
- return parseRunnerConfig(readFileSync(path, "utf8"));
147
+ const config = parseRunnerConfig(readFileSync(path, "utf8"));
148
+ return resolveConfigPaths(config, dirname(path));
149
+ }
150
+
151
+ /** Absolute-resolve one path field against `baseDir` when it is relative. */
152
+ function resolveConfigPath(value: string, baseDir: string): string {
153
+ return isAbsolute(value) ? value : resolve(baseDir, value);
154
+ }
155
+
156
+ /** Absolute-resolve an environment's `serviceAccountKeyPath` against `baseDir`. */
157
+ function resolveEnvironmentPaths(
158
+ env: EnvironmentConfig,
159
+ baseDir: string,
160
+ ): EnvironmentConfig {
161
+ const firestore = env.firestore;
162
+ if (firestore?.serviceAccountKeyPath === undefined) return env;
163
+ return {
164
+ ...env,
165
+ firestore: {
166
+ ...firestore,
167
+ serviceAccountKeyPath: resolveConfigPath(
168
+ firestore.serviceAccountKeyPath,
169
+ baseDir,
170
+ ),
171
+ },
172
+ };
173
+ }
174
+
175
+ /** Absolute-resolve one project's file-referencing fields against `baseDir`. */
176
+ function resolveProjectPaths(
177
+ project: ProjectConfig,
178
+ baseDir: string,
179
+ ): ProjectConfig {
180
+ const environments = Object.fromEntries(
181
+ Object.entries(project.environments).map(([name, env]) => [
182
+ name,
183
+ resolveEnvironmentPaths(env, baseDir),
184
+ ]),
185
+ );
186
+ return {
187
+ ...project,
188
+ environments,
189
+ ...(project.contextFile === undefined
190
+ ? {}
191
+ : { contextFile: resolveConfigPath(project.contextFile, baseDir) }),
192
+ };
193
+ }
194
+
195
+ /** Absolute-resolve every project's file-referencing fields against `baseDir`. */
196
+ function resolveConfigPaths(
197
+ config: RunnerConfig,
198
+ baseDir: string,
199
+ ): RunnerConfig {
200
+ const projects = Object.fromEntries(
201
+ Object.entries(config.projects).map(([slug, project]) => [
202
+ slug,
203
+ resolveProjectPaths(project, baseDir),
204
+ ]),
205
+ );
206
+ return { ...config, projects };
138
207
  }
139
208
 
140
209
  /** Env var carrying the projects map (JSON) for env-driven config. */
@@ -169,41 +238,80 @@ export function buildRunnerConfigFromEnv(
169
238
  `${RUNNER_PROJECTS_ENV} is required to build a config from the environment.`,
170
239
  );
171
240
  }
172
- // Include the control-plane block only when at least one of its vars is set.
173
- // Omitting it entirely keeps `controlPlane` validly optional (so `runner list`
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
-
241
+ const controlPlane = controlPlaneFromEnv(source);
242
+ const secrets = secretsFromEnv(source);
186
243
  const candidate = {
187
244
  projects: parseJsonEnv(projectsRaw, RUNNER_PROJECTS_ENV),
188
- ...(hasAnyControlPlane
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
- }),
245
+ ...(controlPlane === undefined ? {} : { controlPlane }),
246
+ ...(secrets === undefined ? {} : { secrets }),
203
247
  };
204
248
  return RunnerConfigSchema.parse(candidate);
205
249
  }
206
250
 
251
+ /**
252
+ * Overlay deploy-time secrets from the environment onto a **file-loaded**
253
+ * config: the committed config holds only the code-defined structure
254
+ * (projects, environments, actions, context), while the outbound link's creds
255
+ * (`controlPlane`) and runner-level action `secrets` arrive as env vars from a
256
+ * secret manager and are never written to the repo. Env only *fills* what the
257
+ * file left unset; a value already present in the file always wins.
258
+ *
259
+ * This is the counterpart to {@link loadRunnerConfig} for the production path:
260
+ * bake the structural config into the image, inject secrets as env at runtime.
261
+ */
262
+ export function overlayEnvSecrets(
263
+ config: RunnerConfig,
264
+ source: NodeJS.ProcessEnv,
265
+ ): RunnerConfig {
266
+ const controlPlane = config.controlPlane ?? controlPlaneFromEnv(source);
267
+ const secrets = config.secrets ?? secretsFromEnv(source);
268
+ return {
269
+ ...config,
270
+ ...(controlPlane === undefined ? {} : { controlPlane }),
271
+ ...(secrets === undefined ? {} : { secrets }),
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Build the control-plane block from env vars, or `undefined` when none is set
277
+ * (so `runner list` can load actions in a container without enrollment creds).
278
+ * A *partial* block is passed through to the schema so it pinpoints exactly
279
+ * which field is missing rather than silently dropping the block.
280
+ */
281
+ function controlPlaneFromEnv(
282
+ source: NodeJS.ProcessEnv,
283
+ ): ControlPlaneConfig | undefined {
284
+ const url = source["POLYCORE_CONTROL_PLANE_URL"];
285
+ const runnerId = source["POLYCORE_RUNNER_ID"];
286
+ const joinToken = source["POLYCORE_JOIN_TOKEN"];
287
+ const signingSecret = source["POLYCORE_SIGNING_SECRET"];
288
+ if (
289
+ url === undefined &&
290
+ runnerId === undefined &&
291
+ joinToken === undefined &&
292
+ signingSecret === undefined
293
+ ) {
294
+ return undefined;
295
+ }
296
+ return ControlPlaneConfigSchema.parse({
297
+ url,
298
+ runnerId,
299
+ joinToken,
300
+ signingSecret,
301
+ });
302
+ }
303
+
304
+ const runnerSecretsSchema = z.record(z.string(), z.string());
305
+
306
+ /** Runner-level action secrets from `POLYCORE_SECRETS`, or `undefined`. */
307
+ function secretsFromEnv(
308
+ source: NodeJS.ProcessEnv,
309
+ ): Record<string, string> | undefined {
310
+ const raw = source["POLYCORE_SECRETS"];
311
+ if (raw === undefined) return undefined;
312
+ return runnerSecretsSchema.parse(parseJsonEnv(raw, "POLYCORE_SECRETS"));
313
+ }
314
+
207
315
  function parseJsonEnv(raw: string, name: string): unknown {
208
316
  try {
209
317
  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/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 {
@@ -30,6 +33,7 @@ export {
30
33
  FirestoreEnvironmentSchema,
31
34
  hasEnvConfig,
32
35
  loadRunnerConfig,
36
+ overlayEnvSecrets,
33
37
  parseRunnerConfig,
34
38
  ProjectConfigSchema,
35
39
  resolveEnvironment,
@@ -102,20 +106,53 @@ export function createRegistry(
102
106
  return registry;
103
107
  }
104
108
 
109
+ /** The conventional directory customer actions live in, beside the config. */
110
+ export const ACTIONS_DIRNAME = "actions";
111
+
112
+ /**
113
+ * Resolve a project's actions directory by convention, relative to the config
114
+ * file's directory (`baseDir`): a project's actions live in `actions/<slug>/`,
115
+ * and when the runner serves a single project they may live directly in
116
+ * `actions/`. Returns `undefined` when neither exists (a project with no
117
+ * actions, or an env-driven config with no file directory) — the runner then
118
+ * serves only the built-in connectors. There is no `actionsDir` config field:
119
+ * the layout is a fixed convention, exactly like the desktop `src/actions/`.
120
+ */
121
+ export function resolveActionsDir(
122
+ baseDir: string,
123
+ slug: string,
124
+ projectCount: number,
125
+ ): string | undefined {
126
+ const namespaced = join(baseDir, ACTIONS_DIRNAME, slug);
127
+ if (existsSync(namespaced)) return namespaced;
128
+ const shared = join(baseDir, ACTIONS_DIRNAME);
129
+ if (projectCount === 1 && existsSync(shared)) return shared;
130
+ return undefined;
131
+ }
132
+
105
133
  /**
106
134
  * Build one registry per configured project: built-in connectors filtered
107
135
  * by what the project's environments configure, plus the project's own
108
136
  * actions with its secrets (project secrets win over runner-level secrets;
109
137
  * both fall back to `process.env` at dispatch).
138
+ *
139
+ * `actionsBaseDir` is the config file's directory; actions are discovered from
140
+ * the `actions/` convention under it (see {@link resolveActionsDir}). Omit it
141
+ * for an env-driven config with no file location — the runner then serves only
142
+ * the built-in connectors.
110
143
  */
111
144
  export async function buildProjectRegistries(
112
145
  config: RunnerConfig,
146
+ actionsBaseDir?: string,
113
147
  ): Promise<Map<string, ConnectorRegistry>> {
114
148
  const registries = new Map<string, ConnectorRegistry>();
149
+ const projectCount = Object.keys(config.projects).length;
115
150
  for (const [slug, project] of Object.entries(config.projects)) {
116
- const actions = project.actionsDir
117
- ? await loadActions(project.actionsDir)
118
- : [];
151
+ const dir =
152
+ actionsBaseDir === undefined
153
+ ? undefined
154
+ : resolveActionsDir(actionsBaseDir, slug, projectCount);
155
+ const actions = dir ? await loadActions(dir) : [];
119
156
  registries.set(
120
157
  slug,
121
158
  createRegistry({