@cat-factory/executor-harness 1.139.0 → 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 +3 -3
- package/dist/agent-env.d.ts +11 -0
- package/dist/agent-env.js +16 -5
- package/dist/environment-inventory.d.ts +11 -0
- package/dist/environment-inventory.js +27 -1
- package/dist/frontend-infra.js +38 -0
- package/dist/harness-port.d.ts +30 -0
- package/dist/harness-port.js +38 -0
- package/dist/harness-server.js +2 -1
- package/package.json +5 -4
- package/src/agent-env.ts +16 -5
- package/src/environment-inventory.ts +41 -1
- package/src/frontend-infra.ts +44 -0
- package/src/harness-port.ts +40 -0
- package/src/harness-server.ts +2 -1
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 `:
|
|
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`
|
|
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` | `
|
|
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. |
|
package/dist/agent-env.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
// in a checkout silently skips devDependencies, leaving the agent with no test
|
|
9
|
-
// and no build tool. One measured coder run spent six of its forty budgeted
|
|
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
|
package/dist/frontend-infra.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/harness-server.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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/
|
|
29
|
-
"@cat-factory/
|
|
30
|
-
"@cat-factory/
|
|
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
|
|
8
|
-
// in a checkout silently skips devDependencies, leaving the agent with no test
|
|
9
|
-
// and no build tool. One measured coder run spent six of its forty budgeted
|
|
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
|
package/src/frontend-infra.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/harness-server.ts
CHANGED
|
@@ -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 =
|
|
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
|