@cat-factory/executor-harness 1.31.10 → 1.31.12

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.
@@ -59,6 +59,9 @@ function guardProcess(child, label, logger) {
59
59
  export async function standUpFrontend(dir, infra, signal, onActivity, logger = log) {
60
60
  const startedAt = Date.now();
61
61
  const processes = [];
62
+ // The frontend app's directory: the checkout root, or a monorepo subdirectory when the config
63
+ // named one. install/build/serve run here and `outputDir`/`mockMappingsPath` are relative to it.
64
+ const workDir = infra.directory ? join(dir, infra.directory) : dir;
62
65
  // Keep the run's inactivity watchdog fed while the (activity-silent) install → build → serve
63
66
  // stand-up runs. A real frontend's `install` + `build` can exceed the harness inactivity
64
67
  // window (default 10 min, JOB_INACTIVITY_MS) — and unlike the Pi phase this stand-up emits
@@ -96,7 +99,7 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
96
99
  const install = installCommand(infra);
97
100
  logger.info('agent(frontend): installing', { command: install.join(' ') });
98
101
  const installed = await exec(install[0], install.slice(1), {
99
- cwd: dir,
102
+ cwd: workDir,
100
103
  signal,
101
104
  timeout: 8 * 60_000,
102
105
  maxBuffer: 16 * 1024 * 1024,
@@ -107,7 +110,7 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
107
110
  const buildScript = infra.buildScript ?? DEFAULTS.buildScript;
108
111
  logger.info('agent(frontend): building', { buildScript });
109
112
  const built = await exec(pm, ['run', buildScript], {
110
- cwd: dir,
113
+ cwd: workDir,
111
114
  signal,
112
115
  timeout: 12 * 60_000,
113
116
  maxBuffer: 16 * 1024 * 1024,
@@ -128,7 +131,7 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
128
131
  'is only served in static mode; relying on the forwarded env instead', { outputDir });
129
132
  }
130
133
  const shim = `window.env = ${JSON.stringify(infra.env)};\n`;
131
- await writeFile(join(dir, outputDir, 'env.js'), shim, 'utf8').catch((err) => {
134
+ await writeFile(join(workDir, outputDir, 'env.js'), shim, 'utf8').catch((err) => {
132
135
  // Best-effort, but no longer silent: a missing/renamed output dir would drop the shim
133
136
  // and the app would read no URLs, so surface it in the log for diagnosis.
134
137
  logger.warn('agent(frontend): could not write runtime env shim', {
@@ -139,9 +142,9 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
139
142
  }
140
143
  // 3) WireMock for the mocked upstreams. Seeded from the FE repo's mappings dir when present;
141
144
  // otherwise it still binds the port (unmatched requests 404, gentler than ECONNREFUSED).
142
- processes.push(await startWireMock(dir, infra, wiremockPort, logger));
145
+ processes.push(await startWireMock(workDir, infra, wiremockPort, logger));
143
146
  // 4) Serve the built app.
144
- processes.push(startServe(dir, infra, servePort, outputDir, logger));
147
+ processes.push(startServe(workDir, infra, servePort, outputDir, logger));
145
148
  // 5) Health-check the served app AND WireMock before handing off, concurrently (WireMock is
146
149
  // a JVM that cold-starts slower than the static server). A dead WireMock would otherwise let
147
150
  // the agent start and hit ECONNREFUSED on the app's first mocked-upstream call.
package/dist/job.js CHANGED
@@ -81,7 +81,7 @@ function parseHarnessAuth(o) {
81
81
  * `..` segment) — the agent's cwd is built from this, so a hostile value must never
82
82
  * point outside the cloned repo.
83
83
  */
84
- function sanitizeServiceDirectory(value) {
84
+ function sanitizeServiceDirectory(value, field = 'repo.serviceDirectory') {
85
85
  if (typeof value !== 'string')
86
86
  return undefined;
87
87
  const normalized = value
@@ -94,7 +94,7 @@ function sanitizeServiceDirectory(value) {
94
94
  if (segments.length === 0)
95
95
  return undefined;
96
96
  if (segments.some((s) => s === '..')) {
97
- throw new Error("Invalid job: 'repo.serviceDirectory' must be a path inside the repo");
97
+ throw new Error(`Invalid job: '${field}' must be a path inside the repo`);
98
98
  }
99
99
  return segments.join('/');
100
100
  }
@@ -301,8 +301,13 @@ function parseFrontendInfraSpec(o) {
301
301
  }
302
302
  const servePort = port(o.servePort);
303
303
  const wiremockPort = port(o.wiremockPort);
304
+ // The app's monorepo subdirectory becomes the install/build/serve cwd, so it goes through the
305
+ // same escape-guard as `repo.serviceDirectory` — strip slashes and reject any `..` segment so a
306
+ // hostile value can't point the stand-up outside the cloned repo.
307
+ const directory = sanitizeServiceDirectory(o.directory, 'frontend.directory');
304
308
  return {
305
309
  kind: 'frontend',
310
+ ...(directory ? { directory } : {}),
306
311
  ...(packageManager ? { packageManager } : {}),
307
312
  ...(typeof o.install === 'string' && o.install ? { install: o.install } : {}),
308
313
  ...(typeof o.buildScript === 'string' && o.buildScript ? { buildScript: o.buildScript } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.31.10",
3
+ "version": "1.31.12",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.27",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.9",
29
- "@cat-factory/server": "0.66.6",
30
- "@cat-factory/spend": "0.10.71"
29
+ "@cat-factory/spend": "0.10.73",
30
+ "@cat-factory/server": "0.67.0"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -83,6 +83,9 @@ export async function standUpFrontend(
83
83
  ): Promise<FrontendStandUp> {
84
84
  const startedAt = Date.now()
85
85
  const processes: ChildProcess[] = []
86
+ // The frontend app's directory: the checkout root, or a monorepo subdirectory when the config
87
+ // named one. install/build/serve run here and `outputDir`/`mockMappingsPath` are relative to it.
88
+ const workDir = infra.directory ? join(dir, infra.directory) : dir
86
89
  // Keep the run's inactivity watchdog fed while the (activity-silent) install → build → serve
87
90
  // stand-up runs. A real frontend's `install` + `build` can exceed the harness inactivity
88
91
  // window (default 10 min, JOB_INACTIVITY_MS) — and unlike the Pi phase this stand-up emits
@@ -121,7 +124,7 @@ export async function standUpFrontend(
121
124
  const install = installCommand(infra)
122
125
  logger.info('agent(frontend): installing', { command: install.join(' ') })
123
126
  const installed = await exec(install[0]!, install.slice(1), {
124
- cwd: dir,
127
+ cwd: workDir,
125
128
  signal,
126
129
  timeout: 8 * 60_000,
127
130
  maxBuffer: 16 * 1024 * 1024,
@@ -133,7 +136,7 @@ export async function standUpFrontend(
133
136
  const buildScript = infra.buildScript ?? DEFAULTS.buildScript
134
137
  logger.info('agent(frontend): building', { buildScript })
135
138
  const built = await exec(pm, ['run', buildScript], {
136
- cwd: dir,
139
+ cwd: workDir,
137
140
  signal,
138
141
  timeout: 12 * 60_000,
139
142
  maxBuffer: 16 * 1024 * 1024,
@@ -158,7 +161,7 @@ export async function standUpFrontend(
158
161
  )
159
162
  }
160
163
  const shim = `window.env = ${JSON.stringify(infra.env)};\n`
161
- await writeFile(join(dir, outputDir, 'env.js'), shim, 'utf8').catch((err) => {
164
+ await writeFile(join(workDir, outputDir, 'env.js'), shim, 'utf8').catch((err) => {
162
165
  // Best-effort, but no longer silent: a missing/renamed output dir would drop the shim
163
166
  // and the app would read no URLs, so surface it in the log for diagnosis.
164
167
  logger.warn('agent(frontend): could not write runtime env shim', {
@@ -170,10 +173,10 @@ export async function standUpFrontend(
170
173
 
171
174
  // 3) WireMock for the mocked upstreams. Seeded from the FE repo's mappings dir when present;
172
175
  // otherwise it still binds the port (unmatched requests 404, gentler than ECONNREFUSED).
173
- processes.push(await startWireMock(dir, infra, wiremockPort, logger))
176
+ processes.push(await startWireMock(workDir, infra, wiremockPort, logger))
174
177
 
175
178
  // 4) Serve the built app.
176
- processes.push(startServe(dir, infra, servePort, outputDir, logger))
179
+ processes.push(startServe(workDir, infra, servePort, outputDir, logger))
177
180
 
178
181
  // 5) Health-check the served app AND WireMock before handing off, concurrently (WireMock is
179
182
  // a JVM that cold-starts slower than the static server). A dead WireMock would otherwise let
package/src/job.ts CHANGED
@@ -146,7 +146,10 @@ function parseHarnessAuth(o: Record<string, unknown>): HarnessAuthFields {
146
146
  * `..` segment) — the agent's cwd is built from this, so a hostile value must never
147
147
  * point outside the cloned repo.
148
148
  */
149
- function sanitizeServiceDirectory(value: unknown): string | undefined {
149
+ function sanitizeServiceDirectory(
150
+ value: unknown,
151
+ field = 'repo.serviceDirectory',
152
+ ): string | undefined {
150
153
  if (typeof value !== 'string') return undefined
151
154
  const normalized = value
152
155
  .trim()
@@ -156,7 +159,7 @@ function sanitizeServiceDirectory(value: unknown): string | undefined {
156
159
  const segments = normalized.split('/').filter((s) => s !== '' && s !== '.')
157
160
  if (segments.length === 0) return undefined
158
161
  if (segments.some((s) => s === '..')) {
159
- throw new Error("Invalid job: 'repo.serviceDirectory' must be a path inside the repo")
162
+ throw new Error(`Invalid job: '${field}' must be a path inside the repo`)
160
163
  }
161
164
  return segments.join('/')
162
165
  }
@@ -290,6 +293,12 @@ export interface ServiceInfraSpec {
290
293
  */
291
294
  export interface FrontendInfraSpec {
292
295
  kind: 'frontend'
296
+ /**
297
+ * The frontend app's subdirectory within the checkout (a monorepo frontend). Absent ⇒ the
298
+ * checkout root. When set, install/build/serve run there and `outputDir`/`wiremockMappingsPath`
299
+ * are resolved relative to it.
300
+ */
301
+ directory?: string
293
302
  /** Package manager for install/build. Default `pnpm`. */
294
303
  packageManager?: 'pnpm' | 'npm' | 'yarn'
295
304
  /** Explicit install command, overriding the one derived from `packageManager`. */
@@ -659,8 +668,13 @@ function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
659
668
  }
660
669
  const servePort = port(o.servePort)
661
670
  const wiremockPort = port(o.wiremockPort)
671
+ // The app's monorepo subdirectory becomes the install/build/serve cwd, so it goes through the
672
+ // same escape-guard as `repo.serviceDirectory` — strip slashes and reject any `..` segment so a
673
+ // hostile value can't point the stand-up outside the cloned repo.
674
+ const directory = sanitizeServiceDirectory(o.directory, 'frontend.directory')
662
675
  return {
663
676
  kind: 'frontend',
677
+ ...(directory ? { directory } : {}),
664
678
  ...(packageManager ? { packageManager } : {}),
665
679
  ...(typeof o.install === 'string' && o.install ? { install: o.install } : {}),
666
680
  ...(typeof o.buildScript === 'string' && o.buildScript ? { buildScript: o.buildScript } : {}),