@cat-factory/executor-harness 1.147.0 → 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.
@@ -2,7 +2,11 @@ import { execFile } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
  import { log, type Logger } from './logger.js'
4
4
  import { harnessListenPort } from './harness-port.js'
5
- import { probeDockerWorkload, type DockerWorkload } from './docker-capability.js'
5
+ import {
6
+ probeDockerWorkload,
7
+ type ContainerEgress,
8
+ type DockerWorkload,
9
+ } from './docker-capability.js'
6
10
 
7
11
  // ---------------------------------------------------------------------------
8
12
  // What this machine actually has, probed ONCE per job and stated to the agent.
@@ -39,6 +43,12 @@ import { probeDockerWorkload, type DockerWorkload } from './docker-capability.js
39
43
  // all fail (issue #2120). Only a container that RAN settles that, so the reachable case is
40
44
  // split by a real workload (docker-capability.ts) into `usable`, `unusable`, and a daemon
41
45
  // that answered while the check itself could not be carried out.
46
+ // - And a daemon that runs containers is still not one whose containers have a NETWORK, which
47
+ // is the same mistake one level in again. Loading and running a local image needs no network,
48
+ // so a daemon started with `--iptables=false` passes the workload check while every nested
49
+ // container is cut off (issue #2174): what an agent then hits is a `docker build` whose
50
+ // `RUN npm ci` sits in retry backoff for about seven minutes before failing. So `usable`
51
+ // carries its own egress verdict, and the line below says something different for each.
42
52
  //
43
53
  // Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
44
54
  // the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
@@ -89,9 +99,14 @@ export type ToolPresence =
89
99
  * - `serving`: it answered, and the workload check could not be carried out (no payload on
90
100
  * this machine, an unmapped architecture, a timeout). Neither of the other two,
91
101
  * and rendered as "try it if you need it".
102
+ *
103
+ * `usable` then carries what a nested container could REACH, which is a second fact and not a
104
+ * sixth state: what an agent may do with the daemon and what its containers can fetch are
105
+ * different questions, and each of the three egress answers changes the advice without changing
106
+ * the verdict on the daemon.
92
107
  */
93
108
  export type DockerCapability =
94
- | { status: 'usable'; server?: string }
109
+ | { status: 'usable'; server?: string; egress: ContainerEgress }
95
110
  | { status: 'unusable'; server?: string; detail: string }
96
111
  | { status: 'serving'; server?: string; reason: string }
97
112
  | { status: 'absent' }
@@ -411,7 +426,7 @@ async function probeDockerCapability(
411
426
  if (daemon.status === 'unknown') return { status: 'unknown', reason: daemon.reason }
412
427
  const server = daemon.version ? { server: daemon.version } : {}
413
428
  const workload = await (opts.workload ?? probeDockerWorkload)(opts.signal)
414
- if (workload.status === 'usable') return { status: 'usable', ...server }
429
+ if (workload.status === 'usable') return { status: 'usable', ...server, egress: workload.egress }
415
430
  if (workload.status === 'unusable')
416
431
  return { status: 'unusable', ...server, detail: workload.detail }
417
432
  return { status: 'serving', ...server, reason: workload.reason }
@@ -561,10 +576,7 @@ function dockerDaemonLine(daemon: DockerCapability): string {
561
576
  const server = 'server' in daemon && daemon.server ? ` (server ${daemon.server})` : ''
562
577
  switch (daemon.status) {
563
578
  case 'usable':
564
- return (
565
- `A Docker daemon is reachable${server} and the platform ran a container on it: ` +
566
- '`docker build`, `docker run` and `docker compose up` work here.'
567
- )
579
+ return `A Docker daemon is reachable${server} and the platform ran a container on it: ${usableCommands(daemon.egress)} work here. ${egressSentence(daemon.egress)}`
568
580
  case 'unusable':
569
581
  return (
570
582
  `A Docker daemon is reachable${server} but it CANNOT run a container: the platform ` +
@@ -601,6 +613,76 @@ function unnamedCapability(daemon: never): string {
601
613
  return `Whether a Docker daemon is reachable could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(daemon)}): try it if you need it.`
602
614
  }
603
615
 
616
+ /**
617
+ * Which commands the `usable` line may claim, which is the EGRESS verdict's business and not the
618
+ * daemon's.
619
+ *
620
+ * Split out because the line used to open with the full list and then, one sentence later, tell
621
+ * the agent that every `RUN` line which fetches anything fails. A block that also says not to
622
+ * spend turns re-checking it cannot afford to state and then retract the same fact: an agent
623
+ * reading the first sentence has already been told `docker build` works here, which is the exact
624
+ * shape of the lie issue #2174 is about. TOTAL over {@link ContainerEgress}, like its sibling.
625
+ */
626
+ function usableCommands(egress: ContainerEgress): string {
627
+ switch (egress.status) {
628
+ case 'blocked':
629
+ // Deliberately omits `docker build`: it is the one the missing NAT rule actually breaks,
630
+ // and the sentence that follows explains which part of it and why.
631
+ return '`docker run` and `docker compose up` of images that are already built'
632
+ case 'reachable':
633
+ case 'undetermined':
634
+ return '`docker build`, `docker run` and `docker compose up`'
635
+ default:
636
+ return unnamedEgressCommands(egress)
637
+ }
638
+ }
639
+
640
+ /** The commands claimed for an egress verdict this build does not know: none of them. */
641
+ function unnamedEgressCommands(egress: never): string {
642
+ return `the docker commands the platform could name for its verdict ${JSON.stringify(egress)}`
643
+ }
644
+
645
+ /**
646
+ * The second half of the `usable` line: what a container started HERE can reach, and what to do
647
+ * about it. TOTAL over {@link ContainerEgress} for the same reason the line above is over the
648
+ * daemon's states.
649
+ *
650
+ * The `blocked` arm is the one this exists for, and it is precise about WHICH commands break,
651
+ * because "docker has no network" is not true and an agent that believed it would skip work it
652
+ * could have done. The daemon has a network: it pulls base images and compose pulls its services
653
+ * normally. What has none is the container each `RUN` line executes in. The seven minutes are
654
+ * named because the failure does not look like a failure from inside the agent's loop: npm turns
655
+ * "no route" into `EAI_AGAIN` only once its retry backoff gives up, so the build reads as a hang
656
+ * and the natural response is to wait longer.
657
+ */
658
+ function egressSentence(egress: ContainerEgress): string {
659
+ switch (egress.status) {
660
+ case 'reachable':
661
+ return 'A container started here also reaches the network, so a build that installs dependencies works.'
662
+ case 'blocked':
663
+ return (
664
+ 'A container started here could reach NOTHING the platform tried, and that is a fact ' +
665
+ `about this sandbox rather than about your work (${egress.detail}). The daemon itself is ` +
666
+ 'fine: it pulls base images, and `docker compose up` of pre-built images works. What ' +
667
+ 'fails is every `RUN` line in a `docker build` that fetches from the public internet ' +
668
+ '(`npm ci`, `apk add`, `pip install`), and it fails SLOWLY: npm reports `EAI_AGAIN` only ' +
669
+ 'after some seven minutes of retry backoff, so it reads as a hang. Do not wait it out ' +
670
+ 'and do not retry. Vendor what you need, skip the image build, verify some other way, or ' +
671
+ 'say in one line that you could not verify it here. If this project already builds ' +
672
+ 'against a mirror inside this network, that is not one of the addresses tried above and ' +
673
+ 'is worth one attempt.'
674
+ )
675
+ case 'undetermined':
676
+ return `Whether a container started here can reach the network was NOT established (${egress.reason}), so try it if you need it and do not read a failure as a defect in the work.`
677
+ default:
678
+ return unnamedEgress(egress)
679
+ }
680
+ }
681
+
682
+ function unnamedEgress(egress: never): string {
683
+ return `Whether a container started here can reach the network could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(egress)}).`
684
+ }
685
+
604
686
  /**
605
687
  * Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
606
688
  * calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
@@ -633,6 +715,12 @@ export async function appendEnvironmentInventory(
633
715
  .map((t) => t.name)
634
716
  .join(','),
635
717
  dockerDaemon: inventory.dockerDaemon.status,
718
+ // Present only when a nested container was actually asked, which is the one state that has
719
+ // an egress answer at all. A word here for every other state would report "no measurement"
720
+ // and "measured, and it cannot get out" in the same field.
721
+ ...(inventory.dockerDaemon.status === 'usable'
722
+ ? { dockerEgress: inventory.dockerDaemon.egress.status }
723
+ : {}),
636
724
  // The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
637
725
  // an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
638
726
  unknown: inventory.tools
@@ -80,6 +80,9 @@ export async function standUpInfra(
80
80
  // boot record's own words for a daemon that answers and cannot run anything are `serving`
81
81
  // and nothing else, so a log line carrying the record alone describes the wrong failure.
82
82
  dockerWorkload: docker.workload?.status ?? 'unmeasured',
83
+ ...(docker.workload?.status === 'usable'
84
+ ? { dockerEgress: docker.workload.egress.status }
85
+ : {}),
83
86
  ...(docker.workload?.status === 'unusable' ? { dockerDetail: docker.workload.detail } : {}),
84
87
  })
85
88
  return {
@@ -162,11 +165,24 @@ export async function standUpInfra(
162
165
  * container DID on it is `usable`, `unusable`, or a check that could not be carried out. Absent
163
166
  * when nothing was measured at all (an undecided boot record probes nothing), which is not the
164
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.
165
177
  */
166
178
  function workloadRecord(workload: DockerWorkload | undefined): {
167
179
  dockerWorkload?: InfraSetupRecord['dockerWorkload']
180
+ dockerEgress?: InfraSetupRecord['dockerEgress']
168
181
  } {
169
182
  if (!workload) return {}
183
+ if (workload.status === 'usable') {
184
+ return { dockerWorkload: 'usable', dockerEgress: workload.egress.status }
185
+ }
170
186
  return { dockerWorkload: workload.status === 'unknown' ? 'undetermined' : workload.status }
171
187
  }
172
188
 
package/src/job.ts CHANGED
@@ -965,6 +965,16 @@ export interface InfraSetupRecord {
965
965
  * ran and could not tell; ABSENT means nothing was measured at all.
966
966
  */
967
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'
968
978
  /** The repo-relative compose file that was stood up. */
969
979
  composePath?: string
970
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