@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.
- 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 +5 -5
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { scrubbedExcerpt } from './redact.js';
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// The one-container image the platform runs to find out whether this machine's Docker daemon
|
|
4
5
|
// can actually run a container, built here in memory rather than pulled.
|
|
@@ -39,6 +40,140 @@ const PROBE_BINARY_PATH = 'busybox';
|
|
|
39
40
|
export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok';
|
|
40
41
|
/** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
|
|
41
42
|
export const PROBE_COMMAND = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL];
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// The second thing the same image is asked, and the one the marker run above structurally cannot
|
|
45
|
+
// answer: whether a container started on this daemon can reach the NETWORK.
|
|
46
|
+
//
|
|
47
|
+
// Loading and running a local image needs no network at all, so a daemon whose nested containers
|
|
48
|
+
// are cut off passes the marker run exactly as a working one does. That is not hypothetical: the
|
|
49
|
+
// published executor image ran its rootless daemon with `--iptables=false`, which drops the
|
|
50
|
+
// MASQUERADE rule for the bridge, and every nested container on it had no egress whatsoever
|
|
51
|
+
// (issue #2173). The harness reported `dockerDaemon: "usable"` throughout, and each agent
|
|
52
|
+
// discovered otherwise about seven minutes into an `npm ci` inside a `docker build` (issue
|
|
53
|
+
// #2174). An agent TOLD it has no egress can plan around it; an agent told docker works cannot.
|
|
54
|
+
//
|
|
55
|
+
// It runs as its own container rather than as one more command in the marker run, because the
|
|
56
|
+
// marker run is deliberately `--network none`. The two need opposite networking, so they cannot
|
|
57
|
+
// be the same `docker run`, and keeping them apart has a second payoff: a failure of anything
|
|
58
|
+
// below can only ever produce an EGRESS verdict, never a verdict about the daemon.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* What the egress container prints for each observation: the marker, then the exit STATUS of the
|
|
62
|
+
* command that made it.
|
|
63
|
+
*
|
|
64
|
+
* The status rather than a pass/fail marker, because the two failures need different answers.
|
|
65
|
+
* A refused connection is evidence about the network; a 127 is busybox saying it has no such
|
|
66
|
+
* applet, which is evidence about the platform's own probe image and may never be reported as a
|
|
67
|
+
* network that is not there.
|
|
68
|
+
*/
|
|
69
|
+
export const EGRESS_TCP_MARKER = 'cat-factory-egress-tcp=';
|
|
70
|
+
export const EGRESS_DNS_MARKER = 'cat-factory-egress-dns=';
|
|
71
|
+
/** How long the in-container connect may take. Short: a blocked route is silent, not slow. */
|
|
72
|
+
const EGRESS_CONNECT_TIMEOUT_SECONDS = 3;
|
|
73
|
+
/**
|
|
74
|
+
* How long busybox is given to print its own `nc` usage, for the capability check below. Bounded
|
|
75
|
+
* like everything else in that container: an applet that somehow blocks may not take the budget
|
|
76
|
+
* of the measurement it is only a preamble to.
|
|
77
|
+
*/
|
|
78
|
+
const EGRESS_USAGE_TIMEOUT_SECONDS = 2;
|
|
79
|
+
/**
|
|
80
|
+
* How long the in-container lookup may take. Its own ceiling because busybox's `nslookup` retries
|
|
81
|
+
* on its own schedule, and an unbounded one would spend the whole check's budget on the half that
|
|
82
|
+
* is the diagnostic rather than the verdict.
|
|
83
|
+
*/
|
|
84
|
+
const EGRESS_LOOKUP_TIMEOUT_SECONDS = 6;
|
|
85
|
+
/**
|
|
86
|
+
* How much of a rejected setting is quoted back. Enough to recognise which value was refused,
|
|
87
|
+
* short of letting a pasted blob be most of an agent's system prompt.
|
|
88
|
+
*/
|
|
89
|
+
const SETTING_CHARS = 60;
|
|
90
|
+
/** An IPv4 literal. Names are refused on purpose: a target that needs DNS cannot TEST DNS. */
|
|
91
|
+
const IPV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
|
|
92
|
+
/** A hostname, in the narrow shape a DNS lookup can be aimed at. */
|
|
93
|
+
const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
94
|
+
/**
|
|
95
|
+
* Read the configured target, or say why it cannot be used.
|
|
96
|
+
*
|
|
97
|
+
* Validated rather than trusted, and strictly, for two reasons that both matter. The host and the
|
|
98
|
+
* name are interpolated into a `sh -c` script INSIDE the probe container, so anything else there
|
|
99
|
+
* would be running whatever a deployment's environment happened to hold; and a target that is
|
|
100
|
+
* quietly wrong produces a confident `blocked` about a daemon that is fine, which is the exact
|
|
101
|
+
* class of lie this whole module exists to remove. A rejected setting is REPORTED as a check that
|
|
102
|
+
* could not be carried out, never silently swapped for the default: an operator who pointed this
|
|
103
|
+
* at an address their network permits is entitled to find out that it was ignored.
|
|
104
|
+
*/
|
|
105
|
+
export function parseEgressTarget(target, dnsName) {
|
|
106
|
+
const [host = '', port = '', ...rest] = target.split(':');
|
|
107
|
+
if (rest.length > 0 || !IPV4.test(host)) {
|
|
108
|
+
return {
|
|
109
|
+
invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, which is not an \`IPv4:port\` address`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const parsed = Number(port);
|
|
113
|
+
if (!/^[0-9]{1,5}$/.test(port) || parsed < 1 || parsed > 65535) {
|
|
114
|
+
return {
|
|
115
|
+
invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, whose port is not a number between 1 and 65535`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (!HOSTNAME.test(dnsName)) {
|
|
119
|
+
return {
|
|
120
|
+
invalid: `the platform's egress check is configured to resolve \`${scrubbedExcerpt(dnsName, SETTING_CHARS)}\`, which is not a hostname`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { target: { host, port: parsed, dnsName } };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The argv the egress container runs: connect, say what that returned, resolve, say the same.
|
|
127
|
+
*
|
|
128
|
+
* Both observations are made and BOTH are reported, because they fail for different reasons and
|
|
129
|
+
* have different fixes. A connect to a raw address needs only a route; a lookup needs the
|
|
130
|
+
* daemon's embedded resolver to be reachable and to forward. Reporting only the first would call
|
|
131
|
+
* a container with a working route and broken DNS "reachable", and nothing an agent fetches by
|
|
132
|
+
* name would work there.
|
|
133
|
+
*
|
|
134
|
+
* Every applet is called by its full path (`/busybox nc`) rather than by name. The image holds
|
|
135
|
+
* one file and no PATH, and busybox's standalone-shell dispatch is a build-time option nothing
|
|
136
|
+
* here may assume.
|
|
137
|
+
*
|
|
138
|
+
* The connect is the half that is easy to get silently wrong, and `nc -w SEC` alone gets it
|
|
139
|
+
* wrong. busybox documents that flag as the timeout for connects AND FINAL NET READS: once stdin
|
|
140
|
+
* hits EOF `nc` half-closes and then waits to be spoken to, so a connect that SUCCEEDED to a peer
|
|
141
|
+
* which expects the client to speak first (every TLS port, the default `1.1.1.1:443` included)
|
|
142
|
+
* hits the alarm and exits non-zero. Read off the exit status alone that is indistinguishable
|
|
143
|
+
* from a refusal, so a working network reports a route that is not there. `-z` means "connect,
|
|
144
|
+
* then stop", which is the question being asked, so it is used wherever the payload's busybox was
|
|
145
|
+
* built with it; where it was not, the connect runs with no `-w`, which is the build whose `nc`
|
|
146
|
+
* exits on its own when stdin closes.
|
|
147
|
+
*
|
|
148
|
+
* Both halves are wrapped in `${busybox} timeout` either way, since a blackholed route is silent
|
|
149
|
+
* rather than refused and the applet's own ceiling is the thing this comment exists because we
|
|
150
|
+
* cannot assume.
|
|
151
|
+
*/
|
|
152
|
+
export function buildEgressCommand(target) {
|
|
153
|
+
const busybox = `/${PROBE_BINARY_PATH}`;
|
|
154
|
+
const bounded = (seconds, command) => `${busybox} timeout ${seconds} ${command}`;
|
|
155
|
+
const where = `${target.host} ${target.port}`;
|
|
156
|
+
const connectSeconds = EGRESS_CONNECT_TIMEOUT_SECONDS + 1;
|
|
157
|
+
const connect = [
|
|
158
|
+
'nc_z=no',
|
|
159
|
+
`case "$(${bounded(EGRESS_USAGE_TIMEOUT_SECONDS, `${busybox} nc`)} 2>&1)" in *-z*) nc_z=yes ;; esac`,
|
|
160
|
+
'if [ "$nc_z" = yes ]; then',
|
|
161
|
+
` ${bounded(connectSeconds, `${busybox} nc -w ${EGRESS_CONNECT_TIMEOUT_SECONDS} -z ${where}`)} >/dev/null 2>&1`,
|
|
162
|
+
'else',
|
|
163
|
+
` ${bounded(connectSeconds, `${busybox} nc ${where}`)} </dev/null >/dev/null 2>&1`,
|
|
164
|
+
'fi',
|
|
165
|
+
].join('\n');
|
|
166
|
+
const resolve = bounded(EGRESS_LOOKUP_TIMEOUT_SECONDS, `${busybox} nslookup ${target.dnsName}`);
|
|
167
|
+
return [
|
|
168
|
+
busybox,
|
|
169
|
+
'sh',
|
|
170
|
+
'-c',
|
|
171
|
+
[
|
|
172
|
+
`${connect}\necho "${EGRESS_TCP_MARKER}$?"`,
|
|
173
|
+
`${resolve} >/dev/null 2>&1; echo "${EGRESS_DNS_MARKER}$?"`,
|
|
174
|
+
].join('\n'),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
42
177
|
/**
|
|
43
178
|
* Node's architecture names mapped onto the docker name for the same machine.
|
|
44
179
|
*
|
package/dist/docker-status.d.ts
CHANGED
|
@@ -31,7 +31,20 @@ export type DockerSource =
|
|
|
31
31
|
export interface DockerStatus {
|
|
32
32
|
available: boolean | undefined;
|
|
33
33
|
source: DockerSource;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Why, in the entrypoint's own closed vocabulary (`serving`, `serving-without-nat`,
|
|
36
|
+
* `still-starting`, `failed`, `missing`, `unreachable`, `probing`).
|
|
37
|
+
*
|
|
38
|
+
* Reported and never branched on, which is what lets the entrypoint add a word without anything
|
|
39
|
+
* here having to know it. Two are worth knowing about. `serving-without-nat`: the daemon that
|
|
40
|
+
* manages its own firewall rules exited without serving, so the one that came up runs with
|
|
41
|
+
* `--iptables=false` and its NESTED containers have no egress. That is a CAUSE, and the only
|
|
42
|
+
* place one exists; what MEASURES the consequence is the egress half of the workload check
|
|
43
|
+
* (docker-capability.ts), from inside a container, with no way to learn why. `still-starting`
|
|
44
|
+
* is a daemon that had not answered when the boot budget ran out and is STILL RUNNING, which is
|
|
45
|
+
* the one absence that routinely stops being true: it is exactly what the live re-probe below
|
|
46
|
+
* exists to catch.
|
|
47
|
+
*/
|
|
35
48
|
reason: string;
|
|
36
49
|
/** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
|
|
37
50
|
detail?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Logger } from './logger.js';
|
|
2
|
-
import { type DockerWorkload } from './docker-capability.js';
|
|
2
|
+
import { type ContainerEgress, type DockerWorkload } from './docker-capability.js';
|
|
3
3
|
/**
|
|
4
4
|
* What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
|
|
5
5
|
* Classification into presence lives in pure code below, because the two callers classify the
|
|
@@ -50,10 +50,16 @@ export type ToolPresence = {
|
|
|
50
50
|
* - `serving`: it answered, and the workload check could not be carried out (no payload on
|
|
51
51
|
* this machine, an unmapped architecture, a timeout). Neither of the other two,
|
|
52
52
|
* and rendered as "try it if you need it".
|
|
53
|
+
*
|
|
54
|
+
* `usable` then carries what a nested container could REACH, which is a second fact and not a
|
|
55
|
+
* sixth state: what an agent may do with the daemon and what its containers can fetch are
|
|
56
|
+
* different questions, and each of the three egress answers changes the advice without changing
|
|
57
|
+
* the verdict on the daemon.
|
|
53
58
|
*/
|
|
54
59
|
export type DockerCapability = {
|
|
55
60
|
status: 'usable';
|
|
56
61
|
server?: string;
|
|
62
|
+
egress: ContainerEgress;
|
|
57
63
|
} | {
|
|
58
64
|
status: 'unusable';
|
|
59
65
|
server?: string;
|
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
4
|
import { harnessListenPort } from './harness-port.js';
|
|
5
|
-
import { probeDockerWorkload } from './docker-capability.js';
|
|
5
|
+
import { probeDockerWorkload, } from './docker-capability.js';
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// What this machine actually has, probed ONCE per job and stated to the agent.
|
|
8
8
|
//
|
|
@@ -38,6 +38,12 @@ import { probeDockerWorkload } from './docker-capability.js';
|
|
|
38
38
|
// all fail (issue #2120). Only a container that RAN settles that, so the reachable case is
|
|
39
39
|
// split by a real workload (docker-capability.ts) into `usable`, `unusable`, and a daemon
|
|
40
40
|
// that answered while the check itself could not be carried out.
|
|
41
|
+
// - And a daemon that runs containers is still not one whose containers have a NETWORK, which
|
|
42
|
+
// is the same mistake one level in again. Loading and running a local image needs no network,
|
|
43
|
+
// so a daemon started with `--iptables=false` passes the workload check while every nested
|
|
44
|
+
// container is cut off (issue #2174): what an agent then hits is a `docker build` whose
|
|
45
|
+
// `RUN npm ci` sits in retry backoff for about seven minutes before failing. So `usable`
|
|
46
|
+
// carries its own egress verdict, and the line below says something different for each.
|
|
41
47
|
//
|
|
42
48
|
// Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
|
|
43
49
|
// the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
|
|
@@ -289,7 +295,7 @@ async function probeDockerCapability(run, opts) {
|
|
|
289
295
|
const server = daemon.version ? { server: daemon.version } : {};
|
|
290
296
|
const workload = await (opts.workload ?? probeDockerWorkload)(opts.signal);
|
|
291
297
|
if (workload.status === 'usable')
|
|
292
|
-
return { status: 'usable', ...server };
|
|
298
|
+
return { status: 'usable', ...server, egress: workload.egress };
|
|
293
299
|
if (workload.status === 'unusable')
|
|
294
300
|
return { status: 'unusable', ...server, detail: workload.detail };
|
|
295
301
|
return { status: 'serving', ...server, reason: workload.reason };
|
|
@@ -420,8 +426,7 @@ function dockerDaemonLine(daemon) {
|
|
|
420
426
|
const server = 'server' in daemon && daemon.server ? ` (server ${daemon.server})` : '';
|
|
421
427
|
switch (daemon.status) {
|
|
422
428
|
case 'usable':
|
|
423
|
-
return
|
|
424
|
-
'`docker build`, `docker run` and `docker compose up` work here.');
|
|
429
|
+
return `A Docker daemon is reachable${server} and the platform ran a container on it: ${usableCommands(daemon.egress)} work here. ${egressSentence(daemon.egress)}`;
|
|
425
430
|
case 'unusable':
|
|
426
431
|
return (`A Docker daemon is reachable${server} but it CANNOT run a container: the platform ` +
|
|
427
432
|
`built a one-layer image and tried to run it here, and that failed (${daemon.detail}). ` +
|
|
@@ -448,6 +453,70 @@ function dockerDaemonLine(daemon) {
|
|
|
448
453
|
function unnamedCapability(daemon) {
|
|
449
454
|
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.`;
|
|
450
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Which commands the `usable` line may claim, which is the EGRESS verdict's business and not the
|
|
458
|
+
* daemon's.
|
|
459
|
+
*
|
|
460
|
+
* Split out because the line used to open with the full list and then, one sentence later, tell
|
|
461
|
+
* the agent that every `RUN` line which fetches anything fails. A block that also says not to
|
|
462
|
+
* spend turns re-checking it cannot afford to state and then retract the same fact: an agent
|
|
463
|
+
* reading the first sentence has already been told `docker build` works here, which is the exact
|
|
464
|
+
* shape of the lie issue #2174 is about. TOTAL over {@link ContainerEgress}, like its sibling.
|
|
465
|
+
*/
|
|
466
|
+
function usableCommands(egress) {
|
|
467
|
+
switch (egress.status) {
|
|
468
|
+
case 'blocked':
|
|
469
|
+
// Deliberately omits `docker build`: it is the one the missing NAT rule actually breaks,
|
|
470
|
+
// and the sentence that follows explains which part of it and why.
|
|
471
|
+
return '`docker run` and `docker compose up` of images that are already built';
|
|
472
|
+
case 'reachable':
|
|
473
|
+
case 'undetermined':
|
|
474
|
+
return '`docker build`, `docker run` and `docker compose up`';
|
|
475
|
+
default:
|
|
476
|
+
return unnamedEgressCommands(egress);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** The commands claimed for an egress verdict this build does not know: none of them. */
|
|
480
|
+
function unnamedEgressCommands(egress) {
|
|
481
|
+
return `the docker commands the platform could name for its verdict ${JSON.stringify(egress)}`;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The second half of the `usable` line: what a container started HERE can reach, and what to do
|
|
485
|
+
* about it. TOTAL over {@link ContainerEgress} for the same reason the line above is over the
|
|
486
|
+
* daemon's states.
|
|
487
|
+
*
|
|
488
|
+
* The `blocked` arm is the one this exists for, and it is precise about WHICH commands break,
|
|
489
|
+
* because "docker has no network" is not true and an agent that believed it would skip work it
|
|
490
|
+
* could have done. The daemon has a network: it pulls base images and compose pulls its services
|
|
491
|
+
* normally. What has none is the container each `RUN` line executes in. The seven minutes are
|
|
492
|
+
* named because the failure does not look like a failure from inside the agent's loop: npm turns
|
|
493
|
+
* "no route" into `EAI_AGAIN` only once its retry backoff gives up, so the build reads as a hang
|
|
494
|
+
* and the natural response is to wait longer.
|
|
495
|
+
*/
|
|
496
|
+
function egressSentence(egress) {
|
|
497
|
+
switch (egress.status) {
|
|
498
|
+
case 'reachable':
|
|
499
|
+
return 'A container started here also reaches the network, so a build that installs dependencies works.';
|
|
500
|
+
case 'blocked':
|
|
501
|
+
return ('A container started here could reach NOTHING the platform tried, and that is a fact ' +
|
|
502
|
+
`about this sandbox rather than about your work (${egress.detail}). The daemon itself is ` +
|
|
503
|
+
'fine: it pulls base images, and `docker compose up` of pre-built images works. What ' +
|
|
504
|
+
'fails is every `RUN` line in a `docker build` that fetches from the public internet ' +
|
|
505
|
+
'(`npm ci`, `apk add`, `pip install`), and it fails SLOWLY: npm reports `EAI_AGAIN` only ' +
|
|
506
|
+
'after some seven minutes of retry backoff, so it reads as a hang. Do not wait it out ' +
|
|
507
|
+
'and do not retry. Vendor what you need, skip the image build, verify some other way, or ' +
|
|
508
|
+
'say in one line that you could not verify it here. If this project already builds ' +
|
|
509
|
+
'against a mirror inside this network, that is not one of the addresses tried above and ' +
|
|
510
|
+
'is worth one attempt.');
|
|
511
|
+
case 'undetermined':
|
|
512
|
+
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.`;
|
|
513
|
+
default:
|
|
514
|
+
return unnamedEgress(egress);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function unnamedEgress(egress) {
|
|
518
|
+
return `Whether a container started here can reach the network could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(egress)}).`;
|
|
519
|
+
}
|
|
451
520
|
/**
|
|
452
521
|
* Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
|
|
453
522
|
* calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
|
|
@@ -477,6 +546,12 @@ export async function appendEnvironmentInventory(systemPrompt, opts = {}) {
|
|
|
477
546
|
.map((t) => t.name)
|
|
478
547
|
.join(','),
|
|
479
548
|
dockerDaemon: inventory.dockerDaemon.status,
|
|
549
|
+
// Present only when a nested container was actually asked, which is the one state that has
|
|
550
|
+
// an egress answer at all. A word here for every other state would report "no measurement"
|
|
551
|
+
// and "measured, and it cannot get out" in the same field.
|
|
552
|
+
...(inventory.dockerDaemon.status === 'usable'
|
|
553
|
+
? { dockerEgress: inventory.dockerDaemon.egress.status }
|
|
554
|
+
: {}),
|
|
480
555
|
// The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
|
|
481
556
|
// an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
|
|
482
557
|
unknown: inventory.tools
|
package/dist/infra-standup.js
CHANGED
|
@@ -62,6 +62,9 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeLive
|
|
|
62
62
|
// boot record's own words for a daemon that answers and cannot run anything are `serving`
|
|
63
63
|
// and nothing else, so a log line carrying the record alone describes the wrong failure.
|
|
64
64
|
dockerWorkload: docker.workload?.status ?? 'unmeasured',
|
|
65
|
+
...(docker.workload?.status === 'usable'
|
|
66
|
+
? { dockerEgress: docker.workload.egress.status }
|
|
67
|
+
: {}),
|
|
65
68
|
...(docker.workload?.status === 'unusable' ? { dockerDetail: docker.workload.detail } : {}),
|
|
66
69
|
});
|
|
67
70
|
return {
|
|
@@ -140,10 +143,22 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeLive
|
|
|
140
143
|
* container DID on it is `usable`, `unusable`, or a check that could not be carried out. Absent
|
|
141
144
|
* when nothing was measured at all (an undecided boot record probes nothing), which is not the
|
|
142
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.
|
|
143
155
|
*/
|
|
144
156
|
function workloadRecord(workload) {
|
|
145
157
|
if (!workload)
|
|
146
158
|
return {};
|
|
159
|
+
if (workload.status === 'usable') {
|
|
160
|
+
return { dockerWorkload: 'usable', dockerEgress: workload.egress.status };
|
|
161
|
+
}
|
|
147
162
|
return { dockerWorkload: workload.status === 'unknown' ? 'undetermined' : workload.status };
|
|
148
163
|
}
|
|
149
164
|
/**
|
package/dist/job.d.ts
CHANGED
|
@@ -566,6 +566,16 @@ export interface InfraSetupRecord {
|
|
|
566
566
|
* ran and could not tell; ABSENT means nothing was measured at all.
|
|
567
567
|
*/
|
|
568
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';
|
|
569
579
|
/** The repo-relative compose file that was stood up. */
|
|
570
580
|
composePath?: string;
|
|
571
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.
|
|
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.
|
|
29
|
-
"@cat-factory/kernel": "0.
|
|
30
|
-
"@cat-factory/server": "0.
|
|
31
|
-
"@cat-factory/spend": "0.17.
|
|
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
|
@@ -189,9 +189,12 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
|
|
|
189
189
|
// single post-clone place to put this). The pass is sized for that: everything in it runs
|
|
190
190
|
// concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
|
|
191
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
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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.
|
|
195
198
|
|
|
196
199
|
const staged: AgentJob = {
|
|
197
200
|
...job,
|