@cat-factory/executor-harness 1.145.1 → 1.149.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.
@@ -4,7 +4,7 @@
4
4
  // guarantees the matching teardown. `manageInfra` is the one entry point a mode calls.
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
- import { probeDockerServing, readDockerStatus, resolveDockerVerdict, } from './docker-status.js';
7
+ import { probeLiveDockerCapability, readDockerStatus, resolveDockerVerdict, } from './docker-status.js';
8
8
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
9
9
  import { captureRedactedOutput, redactSecrets } from './redact.js';
10
10
  const exec = promisify(execFile);
@@ -15,19 +15,22 @@ const exec = promisify(execFile);
15
15
  * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
16
16
  * no-compose-path runs.
17
17
  *
18
- * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
19
- * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
20
- * talk to, so running compose against it would only turn a fact this container holds into a
21
- * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
22
- * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
23
- * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
24
- * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
25
- * own value).
18
+ * A CONFIRMED absence of a USABLE Docker daemon short-circuits it: the container already knows
19
+ * compose cannot work, so running it would only turn a fact this container holds into an error
20
+ * the agent has to interpret. The record then carries the stated cause plus the two facts that
21
+ * decide where a human should look (`dockerAvailable`: was anything answering, `dockerWorkload`:
22
+ * what a container did on it), which is what makes the Tester step say why it ran no infra
23
+ * instead of looking like a Tester that simply chose not to. Anything OTHER than a confirmed
24
+ * negative attempts as before (`DockerStatus.available` in docker-status.ts states why
25
+ * "undecided" is its own value).
26
26
  *
27
- * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
28
- * against a live daemon first, so a warm-pool container whose sidecar came up late is not
29
- * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
30
- * state both answers on a machine that has its own daemon either way.
27
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks the boot record
28
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not latched
29
+ * into refusing infra that works. It re-checks a recorded PRESENCE too, by running an actual
30
+ * container: a rootless daemon in a sandbox answers `docker version` while being unable to mount
31
+ * an image, and compose against that one died on a mount error inside the very mechanism that
32
+ * exists to explain why infra did not come up. `probe` is that check, injected so the unit suite
33
+ * can state every answer on a machine that has its own daemon either way.
31
34
  *
32
35
  * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
33
36
  * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
@@ -38,26 +41,45 @@ const exec = promisify(execFile);
38
41
  * this container makes about itself, and the acceptance suite can only exercise it on a machine
39
42
  * where the daemon genuinely fails.
40
43
  */
41
- export async function standUpInfra(dir, infra, signal, logger, probe = probeDockerServing) {
44
+ export async function standUpInfra(dir, infra, signal, logger, probe = probeLiveDockerCapability) {
42
45
  if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
43
46
  return { started: false };
44
47
  }
45
48
  const startedAt = Date.now();
46
49
  const recorded = await readDockerStatus();
47
- const docker = await resolveDockerVerdict(recorded, probe);
50
+ const docker = await resolveDockerVerdict(recorded, {
51
+ probe,
52
+ ...(signal ? { signal } : {}),
53
+ logger,
54
+ });
48
55
  if (docker.refusal) {
49
56
  const note = `the dependencies could not be started: ${docker.refusal}`;
50
- logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
57
+ logger.warn('agent(explore): infra stand-up refused, no usable docker daemon', {
51
58
  composePath: infra.composePath,
52
59
  dockerSource: recorded.source,
53
60
  dockerReason: recorded.reason,
61
+ // What the LIVE check found, which is the only place the second refusal cause exists: the
62
+ // boot record's own words for a daemon that answers and cannot run anything are `serving`
63
+ // and nothing else, so a log line carrying the record alone describes the wrong failure.
64
+ dockerWorkload: docker.workload?.status ?? 'unmeasured',
65
+ ...(docker.workload?.status === 'usable'
66
+ ? { dockerEgress: docker.workload.egress.status }
67
+ : {}),
68
+ ...(docker.workload?.status === 'unusable' ? { dockerDetail: docker.workload.detail } : {}),
54
69
  });
55
70
  return {
56
71
  started: false,
57
72
  note,
58
73
  record: {
59
74
  started: false,
60
- dockerAvailable: false,
75
+ // NOT a flat `false`. Two refusals reach this branch and they have opposite fixes: with
76
+ // nothing answering, the executor image or the sandbox running it is what to go and look
77
+ // at; with a daemon that answers and cannot run a container, that daemon is up and an
78
+ // operator sent to restart it finds nothing wrong. `dockerAvailable` answers only the
79
+ // first question and `dockerWorkload` the second, so neither has to carry the other's
80
+ // fact (the same rule the compose-failure branch below states for its own `false`).
81
+ dockerAvailable: docker.daemon === true,
82
+ ...workloadRecord(docker.workload),
61
83
  composePath: infra.composePath,
62
84
  at: Date.now(),
63
85
  durationMs: Date.now() - startedAt,
@@ -76,6 +98,7 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeDock
76
98
  record: {
77
99
  started: true,
78
100
  dockerAvailable: true,
101
+ ...workloadRecord(docker.workload),
79
102
  composePath: infra.composePath,
80
103
  at: Date.now(),
81
104
  durationMs: Date.now() - startedAt,
@@ -96,12 +119,13 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeDock
96
119
  note,
97
120
  record: {
98
121
  started: false,
99
- // A compose failure with a REACHABLE daemon: the two `false`s above and here are
100
- // different diagnoses (nothing to talk to vs the stack itself did not come up), and
101
- // only stating both keeps the second from being read as the first. Read off the
102
- // RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
103
- // actually reached rather than the one its boot record still denies.
104
- ...(docker.available === true ? { dockerAvailable: true } : {}),
122
+ // A compose failure with a REACHABLE daemon, which is a third diagnosis again: the stack
123
+ // itself did not come up. Read off the RESOLVED verdict, so a container whose daemon came
124
+ // up after boot claims the daemon it actually reached rather than the one its boot record
125
+ // still denies, and OMITTED rather than `false` when nothing answered the live check,
126
+ // because the boot record's word for that is a hypothesis and not a measurement.
127
+ ...(docker.daemon === true ? { dockerAvailable: true } : {}),
128
+ ...workloadRecord(docker.workload),
105
129
  composePath: infra.composePath,
106
130
  at: Date.now(),
107
131
  durationMs: Date.now() - startedAt,
@@ -111,6 +135,32 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeDock
111
135
  };
112
136
  }
113
137
  }
138
+ /**
139
+ * What the live check measured, for the record the Tester step shows.
140
+ *
141
+ * Its own field beside `dockerAvailable` because the two answer different questions and only one
142
+ * of them has a boolean's worth of answers: a daemon either answered or it did not, while what a
143
+ * container DID on it is `usable`, `unusable`, or a check that could not be carried out. Absent
144
+ * when nothing was measured at all (an undecided boot record probes nothing), which is not the
145
+ * same as a check that ran and could not tell.
146
+ *
147
+ * THREE fields rather than two, because `usable` is not one fact. A daemon running with
148
+ * `--iptables=false` runs containers perfectly and gives them no network, so it is `usable` and
149
+ * every `docker build` that fetches anything on it is guaranteed to fail. The agent's prompt and
150
+ * `GET /health` both learn that; without `dockerEgress` the record a human reads on the Tester
151
+ * step, and every backend consumer of it, sees the same undifferentiated `usable` as a sandbox
152
+ * where the stack actually works. Present only on `usable`, which is the one verdict that has an
153
+ * egress half at all: on any other, a word here would report "no measurement" and "measured, and
154
+ * it cannot get out" in the same field.
155
+ */
156
+ function workloadRecord(workload) {
157
+ if (!workload)
158
+ return {};
159
+ if (workload.status === 'usable') {
160
+ return { dockerWorkload: 'usable', dockerEgress: workload.egress.status };
161
+ }
162
+ return { dockerWorkload: workload.status === 'unknown' ? 'undetermined' : workload.status };
163
+ }
114
164
  /**
115
165
  * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
116
166
  * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
package/dist/job.d.ts CHANGED
@@ -556,6 +556,26 @@ export interface InfraSetupRecord {
556
556
  * mistake that let a daemon-less image read as an ordinary infra failure for months.
557
557
  */
558
558
  dockerAvailable?: boolean;
559
+ /**
560
+ * What a real container DID on that daemon, when the platform measured it.
561
+ *
562
+ * The third diagnosis, and the one `dockerAvailable` structurally cannot carry: a rootless
563
+ * daemon nested in a sandbox answers throughout while unable to mount any image layer, so it is
564
+ * `dockerAvailable: true` and no stack can come up on it (issue #2120). Reporting that as an
565
+ * absent daemon sends a human to restart one that is already up. `undetermined` is a check that
566
+ * ran and could not tell; ABSENT means nothing was measured at all.
567
+ */
568
+ dockerWorkload?: 'usable' | 'unusable' | 'undetermined';
569
+ /**
570
+ * What a container started ON that daemon could REACH, when the platform measured it.
571
+ *
572
+ * The fourth diagnosis, and the one `dockerWorkload: 'usable'` structurally cannot carry: a
573
+ * rootless daemon started with `--iptables=false` installs no MASQUERADE rule for its bridge,
574
+ * so it runs containers perfectly and none of them has a route out. The stack comes up and
575
+ * every `docker build` that fetches a dependency fails, slowly. Present only alongside
576
+ * `usable`, which is the one verdict with an egress half; absent means nothing measured it.
577
+ */
578
+ dockerEgress?: 'reachable' | 'blocked' | 'undetermined';
559
579
  /** The repo-relative compose file that was stood up. */
560
580
  composePath?: string;
561
581
  /** Epoch ms the stand-up attempt finished. */
package/dist/redact.d.ts CHANGED
@@ -11,6 +11,21 @@ export declare function registerKnownSecrets(values: readonly string[]): void;
11
11
  export declare function redact(input: string, knownSecrets?: readonly string[]): string;
12
12
  /** Pattern + registered-value redaction. Kept for callers without a per-call secret list. */
13
13
  export declare function redactSecrets(input: string): string;
14
+ /**
15
+ * A scrubbed, length-bounded excerpt of a string that is about to be QUOTED at a human or a
16
+ * model: a failing command's output, a rejected setting, a thrown message.
17
+ *
18
+ * One helper rather than a private `bounded()` per module, which is what this replaced. Two of
19
+ * them had drifted: `docker-probe-image.ts` echoed a rejected `HARNESS_DOCKER_EGRESS_TARGET`
20
+ * verbatim into a string that reaches every agent's system prompt and `GET /health`, while its
21
+ * same-named neighbour in `docker-capability.ts` scrubbed first. A proxy URL with an embedded
22
+ * token in that setting is the ordinary way that becomes a leak, and two helpers with one name
23
+ * in sibling files is what kept the divergence invisible.
24
+ *
25
+ * Scrub BEFORE bounding, so the cut cannot land inside a credential and leave half of it
26
+ * quotable, and mark a trimmed value so a reader never takes the head for the whole.
27
+ */
28
+ export declare function scrubbedExcerpt(text: string, maxChars: number): string;
14
29
  /** Cap on captured command output kept on an infra record (tail-biased — failures show last). */
15
30
  export declare const MAX_CAPTURED_OUTPUT_CHARS = 16000;
16
31
  /**
package/dist/redact.js CHANGED
@@ -65,6 +65,24 @@ export function redact(input, knownSecrets = []) {
65
65
  export function redactSecrets(input) {
66
66
  return redact(input);
67
67
  }
68
+ /**
69
+ * A scrubbed, length-bounded excerpt of a string that is about to be QUOTED at a human or a
70
+ * model: a failing command's output, a rejected setting, a thrown message.
71
+ *
72
+ * One helper rather than a private `bounded()` per module, which is what this replaced. Two of
73
+ * them had drifted: `docker-probe-image.ts` echoed a rejected `HARNESS_DOCKER_EGRESS_TARGET`
74
+ * verbatim into a string that reaches every agent's system prompt and `GET /health`, while its
75
+ * same-named neighbour in `docker-capability.ts` scrubbed first. A proxy URL with an embedded
76
+ * token in that setting is the ordinary way that becomes a leak, and two helpers with one name
77
+ * in sibling files is what kept the divergence invisible.
78
+ *
79
+ * Scrub BEFORE bounding, so the cut cannot land inside a credential and leave half of it
80
+ * quotable, and mark a trimmed value so a reader never takes the head for the whole.
81
+ */
82
+ export function scrubbedExcerpt(text, maxChars) {
83
+ const scrubbed = redactSecrets(text);
84
+ return scrubbed.length > maxChars ? `${scrubbed.slice(0, maxChars)}…` : scrubbed;
85
+ }
68
86
  /** Cap on captured command output kept on an infra record (tail-biased — failures show last). */
69
87
  export const MAX_CAPTURED_OUTPUT_CHARS = 16_000;
70
88
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.145.1",
3
+ "version": "1.149.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,10 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "devDependencies": {
28
- "@cat-factory/contracts": "0.334.0",
29
- "@cat-factory/kernel": "0.323.2",
30
- "@cat-factory/server": "0.307.9",
31
- "@cat-factory/spend": "0.16.26",
28
+ "@cat-factory/contracts": "0.343.0",
29
+ "@cat-factory/kernel": "0.332.0",
30
+ "@cat-factory/server": "0.311.3",
31
+ "@cat-factory/spend": "0.17.8",
32
32
  "@hono/node-server": "^2.1.1",
33
33
  "@types/node": "^26.4.0",
34
34
  "hono": "^4.13.5",
package/src/agent.ts CHANGED
@@ -187,8 +187,14 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
187
187
  // This sits on the critical path AHEAD of the clone, which is the cost of having one
188
188
  // composition point instead of one per mode (each mode owns its own clone, so there is no
189
189
  // single post-clone place to put this). The pass is sized for that: everything in it runs
190
- // concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
191
- // deliberate wait is a single short retry for a daemon that is still starting.
190
+ // concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
191
+ // deliberate waits rather than instant answers: one short retry for a daemon that is still
192
+ // starting, and, only once a daemon has answered, the CONTAINERS the platform runs to find out
193
+ // what this daemon can do (`docker-capability.ts`: one to prove it runs a container at all,
194
+ // then one on the default network to see what that container reaches). Both are budgeted, and
195
+ // the pair is memoised per container once it has SETTLED, which is any positive plus every
196
+ // negative that cannot change under a running container. Both take the job's signal, so an
197
+ // abandoned run stops paying at once.
192
198
 
193
199
  const staged: AgentJob = {
194
200
  ...job,