@cat-factory/executor-harness 1.139.1 → 1.141.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
@@ -4,7 +4,7 @@ The payload that runs **inside** a per-run Cloudflare Container (or a
4
4
  [self-hosted runner](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md)) to perform real
5
5
  repo work with the [Pi coding agent](https://github.com/earendil-works/pi).
6
6
 
7
- It is a thin TypeScript wrapper (a `node:http` server on `:8080`) that the
7
+ It is a thin TypeScript wrapper (a `node:http` server on `:27182`) that the
8
8
  Worker drives over a small **job protocol**. Jobs run **asynchronously**: a `POST`
9
9
  accepts the job and returns immediately with a `jobId`; the driver then polls
10
10
  `GET /jobs/{id}` for live progress and the terminal result.
@@ -442,7 +442,7 @@ verdict is stated.
442
442
  | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
443
443
  | `src/logger.ts` | Structured logging. |
444
444
  | `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED absence refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
445
- | `src/agent-env.ts` | The env for anything the harness spawns into the agent's CHECKOUT: its own environment minus the variables that are facts about the HARNESS. Today that is `NODE_ENV` the harness runs in production mode, and an inherited `NODE_ENV=production` makes npm omit devDependencies in a checkout that never asked for it. |
445
+ | `src/agent-env.ts` | The env for anything the harness spawns into the agent's CHECKOUT: its own environment minus the variables that are facts about the HARNESS. Today that is `NODE_ENV` (the harness runs in production mode, and an inherited `NODE_ENV=production` makes npm omit devDependencies in a checkout that never asked for it) and `PORT` (the port this harness is listening on, so a service that reads it would bind the one address in the container's network namespace that is already taken). |
446
446
 
447
447
  ## Runner lifecycle knobs
448
448
 
@@ -451,7 +451,7 @@ runner):
451
451
 
452
452
  | Env var | Default | Effect |
453
453
  | --------------------- | --------------- | ----------------------------------------------------------- |
454
- | `PORT` | `8080` | HTTP port the harness listens on. |
454
+ | `PORT` | `27182` | HTTP port the harness listens on. Deliberately not a port a service under test would pick: the harness shares the container's network namespace with the agent's own processes, and on `8080` it answered their health checks (see `src/harness-port.ts`). |
455
455
  | `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
456
456
  | `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
457
457
  | `JOB_TOOL_SILENCE_MS` | half `JOB_MAX_DURATION_MS` (30m at its default) | Kills an agent that keeps producing output but completes no tool call for this long: the "chatty hang" neither watchdog above can see, since streamed output resets the inactivity timer on every chunk while nothing gets done (stuck-run audit F13). Armed ONLY while an agent CLI that reports completed tool calls is running (each runner opens its own window and closes it on exit), so clone / dependency install / push / a validation loop's check commands sit outside it — they are activity-silent by nature and bounded by their own per-command timeouts — and each repair pass opens a fresh window. Derived from the job ceiling rather than fixed. It fires only when output arrived during the window that elapsed, which is what leaves a genuinely quiet run to `JOB_INACTIVITY_MS` and its clearer diagnostic. `0` disables it. |
@@ -5,6 +5,17 @@
5
5
  * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
6
6
  * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
7
7
  * in `agentEnv`, never in `process.env`).
8
+ *
9
+ * `PORT` clears that bar the same way `NODE_ENV` does, and for the defect that moved the job
10
+ * server off 8080 in the first place. The port is handed to the container explicitly (the
11
+ * Kubernetes pod spec's `env`, the local adapters' `-e PORT=`, the native transport's per-process
12
+ * ephemeral port), so it sits in the harness's own environment, and `listen(process.env.PORT)` is
13
+ * the single most common way a service picks its port. Inherited, it aims the agent's own service
14
+ * at the one port in the namespace that is already taken: the service dies with `EADDRINUSE`, and
15
+ * a health check that also reads `$PORT` gets a 200 from the harness whose body begins
16
+ * `{"status":"ok"}`. That is the platform grading itself green in place of the product, which is
17
+ * exactly what moving the number was meant to stop, so moving it alone would have relocated the
18
+ * collision rather than closed it.
8
19
  */
9
20
  export declare const HARNESS_ONLY_ENV_NAMES: readonly string[];
10
21
  /**
package/dist/agent-env.js CHANGED
@@ -4,10 +4,10 @@
4
4
  //
5
5
  // The rule this exists for: the harness process and the agent's checkout are two different
6
6
  // programs, and a few of the harness's own environment variables are actively wrong for the
7
- // second. `NODE_ENV=production` is the one that bit: npm reads it as `omit=dev`, so `npm install`
8
- // in a checkout silently skips devDependencies, leaving the agent with no test runner, no linter
9
- // and no build tool. One measured coder run spent six of its forty budgeted tool calls
10
- // discovering and undoing that (install, `npm ls`, `npm config get omit`, reinstall with
7
+ // second. `NODE_ENV=production` is the one that bit first: npm reads it as `omit=dev`, so an
8
+ // `npm install` in a checkout silently skips devDependencies, leaving the agent with no test
9
+ // runner, no linter and no build tool. One measured coder run spent six of its forty budgeted
10
+ // tool calls discovering and undoing that (install, `npm ls`, `npm config get omit`, reinstall with
11
11
  // `--include=dev`, re-check the bin directory, approve an install script) — all of it caused by a
12
12
  // variable the platform set, on a project the platform knows nothing about.
13
13
  //
@@ -26,8 +26,19 @@
26
26
  * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
27
27
  * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
28
28
  * in `agentEnv`, never in `process.env`).
29
+ *
30
+ * `PORT` clears that bar the same way `NODE_ENV` does, and for the defect that moved the job
31
+ * server off 8080 in the first place. The port is handed to the container explicitly (the
32
+ * Kubernetes pod spec's `env`, the local adapters' `-e PORT=`, the native transport's per-process
33
+ * ephemeral port), so it sits in the harness's own environment, and `listen(process.env.PORT)` is
34
+ * the single most common way a service picks its port. Inherited, it aims the agent's own service
35
+ * at the one port in the namespace that is already taken: the service dies with `EADDRINUSE`, and
36
+ * a health check that also reads `$PORT` gets a 200 from the harness whose body begins
37
+ * `{"status":"ok"}`. That is the platform grading itself green in place of the product, which is
38
+ * exactly what moving the number was meant to stop, so moving it alone would have relocated the
39
+ * collision rather than closed it.
29
40
  */
30
- export const HARNESS_ONLY_ENV_NAMES = ['NODE_ENV'];
41
+ export const HARNESS_ONLY_ENV_NAMES = ['NODE_ENV', 'PORT'];
31
42
  /**
32
43
  * The child env for a command run in the agent's checkout: the harness's own environment minus
33
44
  * {@link HARNESS_ONLY_ENV_NAMES}, with each layer merged over it in order.
@@ -52,6 +52,12 @@ export interface EnvironmentInventory {
52
52
  * "has not answered yet" is one of its answers (see {@link probeDockerDaemon}).
53
53
  */
54
54
  dockerDaemon: ToolPresence;
55
+ /**
56
+ * The port the harness's own job server holds in this network namespace. Not probed: the
57
+ * process reads its own {@link harnessListenPort}, which is the only honest answer when a
58
+ * deployment overrides `PORT` and the only one available before anything is listening.
59
+ */
60
+ harnessPort: number;
55
61
  }
56
62
  /**
57
63
  * The real runner: spawn the tool, bounded, and report what the spawn did.
@@ -113,6 +119,11 @@ export interface ProbeEnvironmentOptions {
113
119
  * both sides of the branch without an ambient environment variable deciding the outcome for it.
114
120
  */
115
121
  daemonExpected?: boolean;
122
+ /**
123
+ * The port the harness holds, defaulting to what this process is actually listening on. Injected
124
+ * only so the suite can assert the rendered line without an ambient `PORT` deciding its text.
125
+ */
126
+ harnessPort?: number;
116
127
  }
117
128
  /**
118
129
  * Probe the machine. EVERYTHING runs concurrently, the daemon included.
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { log } from './logger.js';
4
+ import { harnessListenPort } from './harness-port.js';
4
5
  // ---------------------------------------------------------------------------
5
6
  // What this machine actually has, probed ONCE per job and stated to the agent.
6
7
  //
@@ -261,7 +262,7 @@ export async function probeEnvironment(run, opts = {}) {
261
262
  }))),
262
263
  probeDockerDaemon(run, opts),
263
264
  ]);
264
- return { tools, dockerDaemon };
265
+ return { tools, dockerDaemon, harnessPort: opts.harnessPort ?? harnessListenPort() };
265
266
  }
266
267
  /**
267
268
  * Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
@@ -342,6 +343,7 @@ export function renderEnvironmentInventory(inventory) {
342
343
  `nor absent: ${unknown.join(', ')}.`);
343
344
  }
344
345
  lines.push(dockerDaemonLine(inventory.dockerDaemon));
346
+ lines.push(harnessPortLine(inventory.harnessPort));
345
347
  // Stated as a FACT and not as an errand. This block is appended to the system prompt after the
346
348
  // effort-report directive, whose closing sentences are the prompt's ordering rule (write the
347
349
  // sentinel, then reply, and no tool call after the reply). "Check for that one yourself before
@@ -352,6 +354,29 @@ export function renderEnvironmentInventory(inventory) {
352
354
  'rather than missing.');
353
355
  return lines.join('\n');
354
356
  }
357
+ /**
358
+ * The one port line: what the platform already holds, and why a request to it is not evidence.
359
+ *
360
+ * Stated because the harness shares this network namespace with everything the agent starts, so a
361
+ * port it holds is a port the agent cannot have, and because the failure that follows is not a
362
+ * refusal but a WRONG ANSWER. The harness answers `/health` with a 200 whose body begins
363
+ * `{"status":"ok"}`, so a tester that probes the port its service was supposed to be on grades the
364
+ * platform green and says nothing. A run hit exactly that: the service under test was specified to
365
+ * listen on 8080, the harness held it, the app died with `EADDRINUSE`, and only a second route with
366
+ * a distinctive body gave the trap away.
367
+ *
368
+ * The default port has since moved out of the range anything reaches for by habit, which is the
369
+ * real fix. This line is what remains true whatever a deployment sets `PORT` to, and it names the
370
+ * NUMBER rather than the danger so an agent can pick another port up front instead of diagnosing a
371
+ * bind failure it did not cause.
372
+ */
373
+ function harnessPortLine(port) {
374
+ return (`Port ${port} is already bound here, by the platform's harness process itself. Do not start ` +
375
+ 'anything on it, and do not read what it serves as your own service: it answers requests, ' +
376
+ `including a 200 on \`/health\` with a JSON body that begins \`{"status":"ok"}\`, so a health ` +
377
+ 'check aimed at it passes without your service ever having run. Bind anything you start ' +
378
+ 'somewhere else.');
379
+ }
355
380
  /** The Docker line, which says something different in each of the three cases. */
356
381
  function dockerDaemonLine(daemon) {
357
382
  if (daemon.status === 'present') {
@@ -387,6 +412,7 @@ export async function appendEnvironmentInventory(systemPrompt, opts = {}) {
387
412
  const inventory = await probeEnvironment(opts.run ?? spawnProbeRunner(opts.signal), {
388
413
  ...(opts.sleep ? { sleep: opts.sleep } : {}),
389
414
  ...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
415
+ ...(opts.harnessPort === undefined ? {} : { harnessPort: opts.harnessPort }),
390
416
  });
391
417
  logger.info('agent: probed the environment', {
392
418
  installed: inventory.tools
@@ -4,6 +4,7 @@ import { writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { killChildProcess } from './process.js';
6
6
  import { agentChildEnv } from './agent-env.js';
7
+ import { harnessListenPort } from './harness-port.js';
7
8
  import { pathExists } from './fs-utils.js';
8
9
  import { captureRedactedOutput, redactSecrets } from './redact.js';
9
10
  import { log } from './logger.js';
@@ -51,6 +52,38 @@ function guardProcess(child, label, logger) {
51
52
  });
52
53
  return child;
53
54
  }
55
+ /**
56
+ * The refusal a configured serve port owes when it is the port this harness process is listening
57
+ * on, else `undefined`.
58
+ *
59
+ * The contracts-side guard (`resolveFrontendServePort`) already reserves the DEFAULT harness port,
60
+ * and that is the right rule where it lives: the backend derives the tester's allowed CORS origin
61
+ * from the same call, so both sides agree on one number. But it is a PREDICTION. A deployment sets
62
+ * `PORT` per pod and a Kubernetes runner pool carries its own `harnessPort`, so the port actually
63
+ * held here can be one the shared constant never named. This is the only place both facts are
64
+ * known, so it is the only place that can tell.
65
+ *
66
+ * It refuses rather than relocating, for the defect that moved the job server off 8080 in the first
67
+ * place: on a collision the serve dies with `EADDRINUSE` and the health check that follows gets a
68
+ * 200 from the HARNESS, so the stand-up would hand the agent a serving app that never started and
69
+ * the tester would grade the platform in its place. Serving somewhere else instead would be a
70
+ * second wrong answer, since the CORS origin the backend allows was derived from the port that was
71
+ * asked for. Naming the collision is what leaves a human something to re-pick.
72
+ */
73
+ function harnessPortCollision(servePort, serveUrl, logger) {
74
+ const harnessPort = harnessListenPort();
75
+ if (servePort !== harnessPort)
76
+ return undefined;
77
+ logger.warn('agent(frontend): serve port collides with the harness port', {
78
+ servePort,
79
+ harnessPort,
80
+ });
81
+ return (`the frontend was not served: its configured port ${servePort} is the port this job's ` +
82
+ `harness is listening on, so the app could not bind it and a health check against ` +
83
+ `${serveUrl} would have been answered by the harness rather than the app. Report this as an ` +
84
+ `infra gap, not an app defect: the frame's serve port (or the runner pool's harness port) ` +
85
+ `has to change.`);
86
+ }
54
87
  /**
55
88
  * Build the frontend, start WireMock, serve the built app and health-check both. Best-effort,
56
89
  * like the docker-compose stand-up: a failed build / server that never binds is surfaced to
@@ -95,6 +128,11 @@ export async function standUpFrontend(dir, infra, run, logger = log) {
95
128
  ...extra,
96
129
  };
97
130
  };
131
+ // Refused BEFORE the install/build spend: a serve port equal to the one this harness holds can
132
+ // never carry the app, and would be GRADED as if it did.
133
+ const collision = harnessPortCollision(servePort, serveUrl, logger);
134
+ if (collision)
135
+ return { processes, note: collision, record: record({ error: collision }) };
98
136
  const buildEnv = (infra.envInjection ?? DEFAULTS.envInjection) === 'build' ? (infra.env ?? {}) : {};
99
137
  // The job's own env (see `RunOptions.agentEnv`) — today the private-registry npmrc pointer.
100
138
  // The stand-up is spawned by the HARNESS, not by the agent, so it does not inherit whatever the
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The default in-container port, and deliberately NOT 8080.
3
+ *
4
+ * The harness is PID 1 of the job container and shares the network namespace with everything the
5
+ * agent starts, so whatever port it holds is a port the agent cannot have. On 8080 that collision
6
+ * was not merely inconvenient, it was a WRONG ANSWER: 8080 is the most common default for a
7
+ * containerised HTTP service, so a service under test started there died with `EADDRINUSE`, and a
8
+ * tester that then probed `http://127.0.0.1:8080/health` got a 200 from the harness whose body
9
+ * begins `{"status":"ok"}`. Every ordinary health assertion (a substring match, `status === 'ok'`,
10
+ * a 200-check) passes against the platform instead of the product, and the run is graded green on
11
+ * a service that never started.
12
+ *
13
+ * The value is arbitrary within three constraints, and only the constraints matter:
14
+ *
15
+ * - above 1023, since the harness runs unprivileged;
16
+ * - nowhere near the ports a developer or an agent reaches for by habit (3000, 4173, 5000, 8000,
17
+ * 8080, 8081, 8443, 8888, 9000, …), which is what rules out the whole low band;
18
+ * - below 32768, the floor of Linux's default ephemeral range, so an OUTBOUND connection from
19
+ * the agent's own processes can never already hold it when the harness starts.
20
+ *
21
+ * Changing it is an image change: the transports address the harness at
22
+ * `@cat-factory/contracts`' `HARNESS_JOB_PORT`, which `harness-contract.conformity.test.ts` pins
23
+ * to this number, and a deployment's own runner pool publishes it pod-side.
24
+ */
25
+ export declare const DEFAULT_HARNESS_PORT = 27182;
26
+ /**
27
+ * The port this process actually listens on: `PORT` when the deployment sets one (the native local
28
+ * transport picks an ephemeral port per harness process and passes it that way), else the default.
29
+ */
30
+ export declare function harnessListenPort(env?: NodeJS.ProcessEnv): number;
@@ -0,0 +1,38 @@
1
+ // The port the harness's own job HTTP server binds inside the job container, and the one fact
2
+ // about it the rest of the harness needs.
3
+ //
4
+ // It is its own module because two unrelated things read it: `harness-server.ts`, which binds it,
5
+ // and `environment-inventory.ts`, which STATES it to the agent. Reading it back off the server
6
+ // module would make the inventory import the process that imports the agent.
7
+ /**
8
+ * The default in-container port, and deliberately NOT 8080.
9
+ *
10
+ * The harness is PID 1 of the job container and shares the network namespace with everything the
11
+ * agent starts, so whatever port it holds is a port the agent cannot have. On 8080 that collision
12
+ * was not merely inconvenient, it was a WRONG ANSWER: 8080 is the most common default for a
13
+ * containerised HTTP service, so a service under test started there died with `EADDRINUSE`, and a
14
+ * tester that then probed `http://127.0.0.1:8080/health` got a 200 from the harness whose body
15
+ * begins `{"status":"ok"}`. Every ordinary health assertion (a substring match, `status === 'ok'`,
16
+ * a 200-check) passes against the platform instead of the product, and the run is graded green on
17
+ * a service that never started.
18
+ *
19
+ * The value is arbitrary within three constraints, and only the constraints matter:
20
+ *
21
+ * - above 1023, since the harness runs unprivileged;
22
+ * - nowhere near the ports a developer or an agent reaches for by habit (3000, 4173, 5000, 8000,
23
+ * 8080, 8081, 8443, 8888, 9000, …), which is what rules out the whole low band;
24
+ * - below 32768, the floor of Linux's default ephemeral range, so an OUTBOUND connection from
25
+ * the agent's own processes can never already hold it when the harness starts.
26
+ *
27
+ * Changing it is an image change: the transports address the harness at
28
+ * `@cat-factory/contracts`' `HARNESS_JOB_PORT`, which `harness-contract.conformity.test.ts` pins
29
+ * to this number, and a deployment's own runner pool publishes it pod-side.
30
+ */
31
+ export const DEFAULT_HARNESS_PORT = 27182;
32
+ /**
33
+ * The port this process actually listens on: `PORT` when the deployment sets one (the native local
34
+ * transport picks an ephemeral port per harness process and passes it that way), else the default.
35
+ */
36
+ export function harnessListenPort(env = process.env) {
37
+ return Number(env.PORT ?? DEFAULT_HARNESS_PORT);
38
+ }
@@ -6,6 +6,7 @@ import { handleAgent } from './agent.js';
6
6
  import { handleInline } from './inline.js';
7
7
  import { redactSecrets } from './git.js';
8
8
  import { readDockerStatus } from './docker-status.js';
9
+ import { harnessListenPort } from './harness-port.js';
9
10
  import { JobRegistry, loadRunnerLimits } from './runner.js';
10
11
  import { log } from './logger.js';
11
12
  import { HARNESS_VERSION } from './version.js';
@@ -24,7 +25,7 @@ import { HARNESS_VERSION } from './version.js';
24
25
  // the service it wrote ran `pkill -f 'node dist/server.js'` to stop it, matched PID 1,
25
26
  // and shut the harness down mid-job. The engine could only see a container that
26
27
  // vanished, so it reported an eviction and re-dispatched into the same trap.
27
- const PORT = Number(process.env.PORT ?? 8080);
28
+ const PORT = harnessListenPort();
28
29
  // Optional bind address. Default (unset) binds all interfaces — a container needs that for
29
30
  // its published port. The native local transport runs the harness UNSANDBOXED on the
30
31
  // developer's host and only ever connects over loopback, so it sets 127.0.0.1 to keep the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.139.1",
3
+ "version": "1.141.0",
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",
@@ -25,9 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "devDependencies": {
28
- "@cat-factory/kernel": "0.321.1",
29
- "@cat-factory/server": "0.307.0",
30
- "@cat-factory/spend": "0.16.18",
28
+ "@cat-factory/contracts": "0.332.0",
29
+ "@cat-factory/kernel": "0.321.3",
30
+ "@cat-factory/server": "0.307.2",
31
+ "@cat-factory/spend": "0.16.20",
31
32
  "@hono/node-server": "^2.1.1",
32
33
  "@types/node": "^26.2.0",
33
34
  "hono": "^4.13.4",
package/src/agent-env.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  //
5
5
  // The rule this exists for: the harness process and the agent's checkout are two different
6
6
  // programs, and a few of the harness's own environment variables are actively wrong for the
7
- // second. `NODE_ENV=production` is the one that bit: npm reads it as `omit=dev`, so `npm install`
8
- // in a checkout silently skips devDependencies, leaving the agent with no test runner, no linter
9
- // and no build tool. One measured coder run spent six of its forty budgeted tool calls
10
- // discovering and undoing that (install, `npm ls`, `npm config get omit`, reinstall with
7
+ // second. `NODE_ENV=production` is the one that bit first: npm reads it as `omit=dev`, so an
8
+ // `npm install` in a checkout silently skips devDependencies, leaving the agent with no test
9
+ // runner, no linter and no build tool. One measured coder run spent six of its forty budgeted
10
+ // tool calls discovering and undoing that (install, `npm ls`, `npm config get omit`, reinstall with
11
11
  // `--include=dev`, re-check the bin directory, approve an install script) — all of it caused by a
12
12
  // variable the platform set, on a project the platform knows nothing about.
13
13
  //
@@ -27,8 +27,19 @@
27
27
  * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
28
28
  * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
29
29
  * in `agentEnv`, never in `process.env`).
30
+ *
31
+ * `PORT` clears that bar the same way `NODE_ENV` does, and for the defect that moved the job
32
+ * server off 8080 in the first place. The port is handed to the container explicitly (the
33
+ * Kubernetes pod spec's `env`, the local adapters' `-e PORT=`, the native transport's per-process
34
+ * ephemeral port), so it sits in the harness's own environment, and `listen(process.env.PORT)` is
35
+ * the single most common way a service picks its port. Inherited, it aims the agent's own service
36
+ * at the one port in the namespace that is already taken: the service dies with `EADDRINUSE`, and
37
+ * a health check that also reads `$PORT` gets a 200 from the harness whose body begins
38
+ * `{"status":"ok"}`. That is the platform grading itself green in place of the product, which is
39
+ * exactly what moving the number was meant to stop, so moving it alone would have relocated the
40
+ * collision rather than closed it.
30
41
  */
31
- export const HARNESS_ONLY_ENV_NAMES: readonly string[] = ['NODE_ENV']
42
+ export const HARNESS_ONLY_ENV_NAMES: readonly string[] = ['NODE_ENV', 'PORT']
32
43
 
33
44
  /**
34
45
  * The child env for a command run in the agent's checkout: the harness's own environment minus
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
  import { log, type Logger } from './logger.js'
4
+ import { harnessListenPort } from './harness-port.js'
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // What this machine actually has, probed ONCE per job and stated to the agent.
@@ -85,6 +86,12 @@ export interface EnvironmentInventory {
85
86
  * "has not answered yet" is one of its answers (see {@link probeDockerDaemon}).
86
87
  */
87
88
  dockerDaemon: ToolPresence
89
+ /**
90
+ * The port the harness's own job server holds in this network namespace. Not probed: the
91
+ * process reads its own {@link harnessListenPort}, which is the only honest answer when a
92
+ * deployment overrides `PORT` and the only one available before anything is listening.
93
+ */
94
+ harnessPort: number
88
95
  }
89
96
 
90
97
  /**
@@ -298,6 +305,11 @@ export interface ProbeEnvironmentOptions {
298
305
  * both sides of the branch without an ambient environment variable deciding the outcome for it.
299
306
  */
300
307
  daemonExpected?: boolean
308
+ /**
309
+ * The port the harness holds, defaulting to what this process is actually listening on. Injected
310
+ * only so the suite can assert the rendered line without an ambient `PORT` deciding its text.
311
+ */
312
+ harnessPort?: number
301
313
  }
302
314
 
303
315
  /**
@@ -336,7 +348,7 @@ export async function probeEnvironment(
336
348
  ),
337
349
  probeDockerDaemon(run, opts),
338
350
  ])
339
- return { tools, dockerDaemon }
351
+ return { tools, dockerDaemon, harnessPort: opts.harnessPort ?? harnessListenPort() }
340
352
  }
341
353
 
342
354
  /**
@@ -430,6 +442,7 @@ export function renderEnvironmentInventory(inventory: EnvironmentInventory): str
430
442
  )
431
443
  }
432
444
  lines.push(dockerDaemonLine(inventory.dockerDaemon))
445
+ lines.push(harnessPortLine(inventory.harnessPort))
433
446
  // Stated as a FACT and not as an errand. This block is appended to the system prompt after the
434
447
  // effort-report directive, whose closing sentences are the prompt's ordering rule (write the
435
448
  // sentinel, then reply, and no tool call after the reply). "Check for that one yourself before
@@ -443,6 +456,32 @@ export function renderEnvironmentInventory(inventory: EnvironmentInventory): str
443
456
  return lines.join('\n')
444
457
  }
445
458
 
459
+ /**
460
+ * The one port line: what the platform already holds, and why a request to it is not evidence.
461
+ *
462
+ * Stated because the harness shares this network namespace with everything the agent starts, so a
463
+ * port it holds is a port the agent cannot have, and because the failure that follows is not a
464
+ * refusal but a WRONG ANSWER. The harness answers `/health` with a 200 whose body begins
465
+ * `{"status":"ok"}`, so a tester that probes the port its service was supposed to be on grades the
466
+ * platform green and says nothing. A run hit exactly that: the service under test was specified to
467
+ * listen on 8080, the harness held it, the app died with `EADDRINUSE`, and only a second route with
468
+ * a distinctive body gave the trap away.
469
+ *
470
+ * The default port has since moved out of the range anything reaches for by habit, which is the
471
+ * real fix. This line is what remains true whatever a deployment sets `PORT` to, and it names the
472
+ * NUMBER rather than the danger so an agent can pick another port up front instead of diagnosing a
473
+ * bind failure it did not cause.
474
+ */
475
+ function harnessPortLine(port: number): string {
476
+ return (
477
+ `Port ${port} is already bound here, by the platform's harness process itself. Do not start ` +
478
+ 'anything on it, and do not read what it serves as your own service: it answers requests, ' +
479
+ `including a 200 on \`/health\` with a JSON body that begins \`{"status":"ok"}\`, so a health ` +
480
+ 'check aimed at it passes without your service ever having run. Bind anything you start ' +
481
+ 'somewhere else.'
482
+ )
483
+ }
484
+
446
485
  /** The Docker line, which says something different in each of the three cases. */
447
486
  function dockerDaemonLine(daemon: ToolPresence): string {
448
487
  if (daemon.status === 'present') {
@@ -488,6 +527,7 @@ export async function appendEnvironmentInventory(
488
527
  const inventory = await probeEnvironment(opts.run ?? spawnProbeRunner(opts.signal), {
489
528
  ...(opts.sleep ? { sleep: opts.sleep } : {}),
490
529
  ...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
530
+ ...(opts.harnessPort === undefined ? {} : { harnessPort: opts.harnessPort }),
491
531
  })
492
532
  logger.info('agent: probed the environment', {
493
533
  installed: inventory.tools
@@ -6,6 +6,7 @@ import type { FrontendInfraSpec, InfraSetupRecord } from './job.js'
6
6
  import type { RunOptions } from './runner.js'
7
7
  import { killChildProcess } from './process.js'
8
8
  import { agentChildEnv } from './agent-env.js'
9
+ import { harnessListenPort } from './harness-port.js'
9
10
  import { pathExists } from './fs-utils.js'
10
11
  import { captureRedactedOutput, redactSecrets } from './redact.js'
11
12
  import { log, type Logger } from './logger.js'
@@ -70,6 +71,44 @@ function guardProcess(child: ChildProcess, label: string, logger: Logger): Child
70
71
  return child
71
72
  }
72
73
 
74
+ /**
75
+ * The refusal a configured serve port owes when it is the port this harness process is listening
76
+ * on, else `undefined`.
77
+ *
78
+ * The contracts-side guard (`resolveFrontendServePort`) already reserves the DEFAULT harness port,
79
+ * and that is the right rule where it lives: the backend derives the tester's allowed CORS origin
80
+ * from the same call, so both sides agree on one number. But it is a PREDICTION. A deployment sets
81
+ * `PORT` per pod and a Kubernetes runner pool carries its own `harnessPort`, so the port actually
82
+ * held here can be one the shared constant never named. This is the only place both facts are
83
+ * known, so it is the only place that can tell.
84
+ *
85
+ * It refuses rather than relocating, for the defect that moved the job server off 8080 in the first
86
+ * place: on a collision the serve dies with `EADDRINUSE` and the health check that follows gets a
87
+ * 200 from the HARNESS, so the stand-up would hand the agent a serving app that never started and
88
+ * the tester would grade the platform in its place. Serving somewhere else instead would be a
89
+ * second wrong answer, since the CORS origin the backend allows was derived from the port that was
90
+ * asked for. Naming the collision is what leaves a human something to re-pick.
91
+ */
92
+ function harnessPortCollision(
93
+ servePort: number,
94
+ serveUrl: string,
95
+ logger: Logger,
96
+ ): string | undefined {
97
+ const harnessPort = harnessListenPort()
98
+ if (servePort !== harnessPort) return undefined
99
+ logger.warn('agent(frontend): serve port collides with the harness port', {
100
+ servePort,
101
+ harnessPort,
102
+ })
103
+ return (
104
+ `the frontend was not served: its configured port ${servePort} is the port this job's ` +
105
+ `harness is listening on, so the app could not bind it and a health check against ` +
106
+ `${serveUrl} would have been answered by the harness rather than the app. Report this as an ` +
107
+ `infra gap, not an app defect: the frame's serve port (or the runner pool's harness port) ` +
108
+ `has to change.`
109
+ )
110
+ }
111
+
73
112
  /**
74
113
  * Build the frontend, start WireMock, serve the built app and health-check both. Best-effort,
75
114
  * like the docker-compose stand-up: a failed build / server that never binds is surfaced to
@@ -118,6 +157,11 @@ export async function standUpFrontend(
118
157
  }
119
158
  }
120
159
 
160
+ // Refused BEFORE the install/build spend: a serve port equal to the one this harness holds can
161
+ // never carry the app, and would be GRADED as if it did.
162
+ const collision = harnessPortCollision(servePort, serveUrl, logger)
163
+ if (collision) return { processes, note: collision, record: record({ error: collision }) }
164
+
121
165
  const buildEnv =
122
166
  (infra.envInjection ?? DEFAULTS.envInjection) === 'build' ? (infra.env ?? {}) : {}
123
167
  // The job's own env (see `RunOptions.agentEnv`) — today the private-registry npmrc pointer.
@@ -0,0 +1,40 @@
1
+ // The port the harness's own job HTTP server binds inside the job container, and the one fact
2
+ // about it the rest of the harness needs.
3
+ //
4
+ // It is its own module because two unrelated things read it: `harness-server.ts`, which binds it,
5
+ // and `environment-inventory.ts`, which STATES it to the agent. Reading it back off the server
6
+ // module would make the inventory import the process that imports the agent.
7
+
8
+ /**
9
+ * The default in-container port, and deliberately NOT 8080.
10
+ *
11
+ * The harness is PID 1 of the job container and shares the network namespace with everything the
12
+ * agent starts, so whatever port it holds is a port the agent cannot have. On 8080 that collision
13
+ * was not merely inconvenient, it was a WRONG ANSWER: 8080 is the most common default for a
14
+ * containerised HTTP service, so a service under test started there died with `EADDRINUSE`, and a
15
+ * tester that then probed `http://127.0.0.1:8080/health` got a 200 from the harness whose body
16
+ * begins `{"status":"ok"}`. Every ordinary health assertion (a substring match, `status === 'ok'`,
17
+ * a 200-check) passes against the platform instead of the product, and the run is graded green on
18
+ * a service that never started.
19
+ *
20
+ * The value is arbitrary within three constraints, and only the constraints matter:
21
+ *
22
+ * - above 1023, since the harness runs unprivileged;
23
+ * - nowhere near the ports a developer or an agent reaches for by habit (3000, 4173, 5000, 8000,
24
+ * 8080, 8081, 8443, 8888, 9000, …), which is what rules out the whole low band;
25
+ * - below 32768, the floor of Linux's default ephemeral range, so an OUTBOUND connection from
26
+ * the agent's own processes can never already hold it when the harness starts.
27
+ *
28
+ * Changing it is an image change: the transports address the harness at
29
+ * `@cat-factory/contracts`' `HARNESS_JOB_PORT`, which `harness-contract.conformity.test.ts` pins
30
+ * to this number, and a deployment's own runner pool publishes it pod-side.
31
+ */
32
+ export const DEFAULT_HARNESS_PORT = 27182
33
+
34
+ /**
35
+ * The port this process actually listens on: `PORT` when the deployment sets one (the native local
36
+ * transport picks an ephemeral port per harness process and passes it that way), else the default.
37
+ */
38
+ export function harnessListenPort(env: NodeJS.ProcessEnv = process.env): number {
39
+ return Number(env.PORT ?? DEFAULT_HARNESS_PORT)
40
+ }
@@ -6,6 +6,7 @@ import { handleAgent } from './agent.js'
6
6
  import { handleInline } from './inline.js'
7
7
  import { redactSecrets } from './git.js'
8
8
  import { readDockerStatus } from './docker-status.js'
9
+ import { harnessListenPort } from './harness-port.js'
9
10
  import { JobRegistry, loadRunnerLimits, type JobResultBase, type RunOptions } from './runner.js'
10
11
  import { log } from './logger.js'
11
12
  import { HARNESS_VERSION } from './version.js'
@@ -26,7 +27,7 @@ import { HARNESS_VERSION } from './version.js'
26
27
  // and shut the harness down mid-job. The engine could only see a container that
27
28
  // vanished, so it reported an eviction and re-dispatched into the same trap.
28
29
 
29
- const PORT = Number(process.env.PORT ?? 8080)
30
+ const PORT = harnessListenPort()
30
31
 
31
32
  // Optional bind address. Default (unset) binds all interfaces — a container needs that for
32
33
  // its published port. The native local transport runs the harness UNSANDBOXED on the