@cat-factory/executor-harness 1.147.0 → 1.151.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 +62 -9
- package/dist/agent.js +6 -3
- package/dist/docker-capability.d.ts +59 -0
- package/dist/docker-capability.js +176 -17
- package/dist/docker-probe-image.d.ts +61 -0
- package/dist/docker-probe-image.js +135 -0
- package/dist/docker-status.d.ts +14 -1
- package/dist/environment-inventory.d.ts +7 -1
- package/dist/environment-inventory.js +79 -4
- package/dist/infra-standup.js +15 -0
- package/dist/job.d.ts +10 -0
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +18 -0
- package/package.json +6 -6
- package/src/agent.ts +6 -3
- package/src/docker-capability.ts +259 -10
- package/src/docker-probe-image.ts +157 -0
- package/src/docker-status.ts +14 -1
- package/src/environment-inventory.ts +95 -7
- package/src/infra-standup.ts +16 -0
- package/src/job.ts +10 -0
- package/src/redact.ts +19 -0
|
@@ -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 {
|
|
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
|
package/src/infra-standup.ts
CHANGED
|
@@ -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
|
|