@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.
@@ -6,9 +6,10 @@
6
6
  import { execFile } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
8
  import type { AgentInfraSpec, InfraSetupRecord, ServiceInfraSpec } from './job.js'
9
+ import type { DockerWorkload } from './docker-capability.js'
9
10
  import {
10
11
  type DockerProbe,
11
- probeDockerServing,
12
+ probeLiveDockerCapability,
12
13
  readDockerStatus,
13
14
  resolveDockerVerdict,
14
15
  } from './docker-status.js'
@@ -26,19 +27,22 @@ const exec = promisify(execFile)
26
27
  * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
27
28
  * no-compose-path runs.
28
29
  *
29
- * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
30
- * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
31
- * talk to, so running compose against it would only turn a fact this container holds into a
32
- * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
33
- * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
34
- * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
35
- * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
36
- * own value).
30
+ * A CONFIRMED absence of a USABLE Docker daemon short-circuits it: the container already knows
31
+ * compose cannot work, so running it would only turn a fact this container holds into an error
32
+ * the agent has to interpret. The record then carries the stated cause plus the two facts that
33
+ * decide where a human should look (`dockerAvailable`: was anything answering, `dockerWorkload`:
34
+ * what a container did on it), which is what makes the Tester step say why it ran no infra
35
+ * instead of looking like a Tester that simply chose not to. Anything OTHER than a confirmed
36
+ * negative attempts as before (`DockerStatus.available` in docker-status.ts states why
37
+ * "undecided" is its own value).
37
38
  *
38
- * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
39
- * against a live daemon first, so a warm-pool container whose sidecar came up late is not
40
- * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
41
- * state both answers on a machine that has its own daemon either way.
39
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks the boot record
40
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not latched
41
+ * into refusing infra that works. It re-checks a recorded PRESENCE too, by running an actual
42
+ * container: a rootless daemon in a sandbox answers `docker version` while being unable to mount
43
+ * an image, and compose against that one died on a mount error inside the very mechanism that
44
+ * exists to explain why infra did not come up. `probe` is that check, injected so the unit suite
45
+ * can state every answer on a machine that has its own daemon either way.
42
46
  *
43
47
  * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
44
48
  * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
@@ -54,27 +58,46 @@ export async function standUpInfra(
54
58
  infra: ServiceInfraSpec,
55
59
  signal: AbortSignal | undefined,
56
60
  logger: Logger,
57
- probe: DockerProbe = probeDockerServing,
61
+ probe: DockerProbe = probeLiveDockerCapability,
58
62
  ): Promise<{ started: boolean; note?: string; record?: InfraSetupRecord }> {
59
63
  if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
60
64
  return { started: false }
61
65
  }
62
66
  const startedAt = Date.now()
63
67
  const recorded = await readDockerStatus()
64
- const docker = await resolveDockerVerdict(recorded, probe)
68
+ const docker = await resolveDockerVerdict(recorded, {
69
+ probe,
70
+ ...(signal ? { signal } : {}),
71
+ logger,
72
+ })
65
73
  if (docker.refusal) {
66
74
  const note = `the dependencies could not be started: ${docker.refusal}`
67
- logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
75
+ logger.warn('agent(explore): infra stand-up refused, no usable docker daemon', {
68
76
  composePath: infra.composePath,
69
77
  dockerSource: recorded.source,
70
78
  dockerReason: recorded.reason,
79
+ // What the LIVE check found, which is the only place the second refusal cause exists: the
80
+ // boot record's own words for a daemon that answers and cannot run anything are `serving`
81
+ // and nothing else, so a log line carrying the record alone describes the wrong failure.
82
+ dockerWorkload: docker.workload?.status ?? 'unmeasured',
83
+ ...(docker.workload?.status === 'usable'
84
+ ? { dockerEgress: docker.workload.egress.status }
85
+ : {}),
86
+ ...(docker.workload?.status === 'unusable' ? { dockerDetail: docker.workload.detail } : {}),
71
87
  })
72
88
  return {
73
89
  started: false,
74
90
  note,
75
91
  record: {
76
92
  started: false,
77
- dockerAvailable: false,
93
+ // NOT a flat `false`. Two refusals reach this branch and they have opposite fixes: with
94
+ // nothing answering, the executor image or the sandbox running it is what to go and look
95
+ // at; with a daemon that answers and cannot run a container, that daemon is up and an
96
+ // operator sent to restart it finds nothing wrong. `dockerAvailable` answers only the
97
+ // first question and `dockerWorkload` the second, so neither has to carry the other's
98
+ // fact (the same rule the compose-failure branch below states for its own `false`).
99
+ dockerAvailable: docker.daemon === true,
100
+ ...workloadRecord(docker.workload),
78
101
  composePath: infra.composePath,
79
102
  at: Date.now(),
80
103
  durationMs: Date.now() - startedAt,
@@ -97,6 +120,7 @@ export async function standUpInfra(
97
120
  record: {
98
121
  started: true,
99
122
  dockerAvailable: true,
123
+ ...workloadRecord(docker.workload),
100
124
  composePath: infra.composePath,
101
125
  at: Date.now(),
102
126
  durationMs: Date.now() - startedAt,
@@ -116,12 +140,13 @@ export async function standUpInfra(
116
140
  note,
117
141
  record: {
118
142
  started: false,
119
- // A compose failure with a REACHABLE daemon: the two `false`s above and here are
120
- // different diagnoses (nothing to talk to vs the stack itself did not come up), and
121
- // only stating both keeps the second from being read as the first. Read off the
122
- // RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
123
- // actually reached rather than the one its boot record still denies.
124
- ...(docker.available === true ? { dockerAvailable: true } : {}),
143
+ // A compose failure with a REACHABLE daemon, which is a third diagnosis again: the stack
144
+ // itself did not come up. Read off the RESOLVED verdict, so a container whose daemon came
145
+ // up after boot claims the daemon it actually reached rather than the one its boot record
146
+ // still denies, and OMITTED rather than `false` when nothing answered the live check,
147
+ // because the boot record's word for that is a hypothesis and not a measurement.
148
+ ...(docker.daemon === true ? { dockerAvailable: true } : {}),
149
+ ...workloadRecord(docker.workload),
125
150
  composePath: infra.composePath,
126
151
  at: Date.now(),
127
152
  durationMs: Date.now() - startedAt,
@@ -132,6 +157,35 @@ export async function standUpInfra(
132
157
  }
133
158
  }
134
159
 
160
+ /**
161
+ * What the live check measured, for the record the Tester step shows.
162
+ *
163
+ * Its own field beside `dockerAvailable` because the two answer different questions and only one
164
+ * of them has a boolean's worth of answers: a daemon either answered or it did not, while what a
165
+ * container DID on it is `usable`, `unusable`, or a check that could not be carried out. Absent
166
+ * when nothing was measured at all (an undecided boot record probes nothing), which is not the
167
+ * same as a check that ran and could not tell.
168
+ *
169
+ * THREE fields rather than two, because `usable` is not one fact. A daemon running with
170
+ * `--iptables=false` runs containers perfectly and gives them no network, so it is `usable` and
171
+ * every `docker build` that fetches anything on it is guaranteed to fail. The agent's prompt and
172
+ * `GET /health` both learn that; without `dockerEgress` the record a human reads on the Tester
173
+ * step, and every backend consumer of it, sees the same undifferentiated `usable` as a sandbox
174
+ * where the stack actually works. Present only on `usable`, which is the one verdict that has an
175
+ * egress half at all: on any other, a word here would report "no measurement" and "measured, and
176
+ * it cannot get out" in the same field.
177
+ */
178
+ function workloadRecord(workload: DockerWorkload | undefined): {
179
+ dockerWorkload?: InfraSetupRecord['dockerWorkload']
180
+ dockerEgress?: InfraSetupRecord['dockerEgress']
181
+ } {
182
+ if (!workload) return {}
183
+ if (workload.status === 'usable') {
184
+ return { dockerWorkload: 'usable', dockerEgress: workload.egress.status }
185
+ }
186
+ return { dockerWorkload: workload.status === 'unknown' ? 'undetermined' : workload.status }
187
+ }
188
+
135
189
  /**
136
190
  * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
137
191
  * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
package/src/job.ts CHANGED
@@ -955,6 +955,26 @@ export interface InfraSetupRecord {
955
955
  * mistake that let a daemon-less image read as an ordinary infra failure for months.
956
956
  */
957
957
  dockerAvailable?: boolean
958
+ /**
959
+ * What a real container DID on that daemon, when the platform measured it.
960
+ *
961
+ * The third diagnosis, and the one `dockerAvailable` structurally cannot carry: a rootless
962
+ * daemon nested in a sandbox answers throughout while unable to mount any image layer, so it is
963
+ * `dockerAvailable: true` and no stack can come up on it (issue #2120). Reporting that as an
964
+ * absent daemon sends a human to restart one that is already up. `undetermined` is a check that
965
+ * ran and could not tell; ABSENT means nothing was measured at all.
966
+ */
967
+ dockerWorkload?: 'usable' | 'unusable' | 'undetermined'
968
+ /**
969
+ * What a container started ON that daemon could REACH, when the platform measured it.
970
+ *
971
+ * The fourth diagnosis, and the one `dockerWorkload: 'usable'` structurally cannot carry: a
972
+ * rootless daemon started with `--iptables=false` installs no MASQUERADE rule for its bridge,
973
+ * so it runs containers perfectly and none of them has a route out. The stack comes up and
974
+ * every `docker build` that fetches a dependency fails, slowly. Present only alongside
975
+ * `usable`, which is the one verdict with an egress half; absent means nothing measured it.
976
+ */
977
+ dockerEgress?: 'reachable' | 'blocked' | 'undetermined'
958
978
  /** The repo-relative compose file that was stood up. */
959
979
  composePath?: string
960
980
  /** Epoch ms the stand-up attempt finished. */
package/src/redact.ts CHANGED
@@ -72,6 +72,25 @@ export function redactSecrets(input: string): string {
72
72
  return redact(input)
73
73
  }
74
74
 
75
+ /**
76
+ * A scrubbed, length-bounded excerpt of a string that is about to be QUOTED at a human or a
77
+ * model: a failing command's output, a rejected setting, a thrown message.
78
+ *
79
+ * One helper rather than a private `bounded()` per module, which is what this replaced. Two of
80
+ * them had drifted: `docker-probe-image.ts` echoed a rejected `HARNESS_DOCKER_EGRESS_TARGET`
81
+ * verbatim into a string that reaches every agent's system prompt and `GET /health`, while its
82
+ * same-named neighbour in `docker-capability.ts` scrubbed first. A proxy URL with an embedded
83
+ * token in that setting is the ordinary way that becomes a leak, and two helpers with one name
84
+ * in sibling files is what kept the divergence invisible.
85
+ *
86
+ * Scrub BEFORE bounding, so the cut cannot land inside a credential and leave half of it
87
+ * quotable, and mark a trimmed value so a reader never takes the head for the whole.
88
+ */
89
+ export function scrubbedExcerpt(text: string, maxChars: number): string {
90
+ const scrubbed = redactSecrets(text)
91
+ return scrubbed.length > maxChars ? `${scrubbed.slice(0, maxChars)}…` : scrubbed
92
+ }
93
+
75
94
  /** Cap on captured command output kept on an infra record (tail-biased — failures show last). */
76
95
  export const MAX_CAPTURED_OUTPUT_CHARS = 16_000
77
96